gabriel / musehub public
test_account_deletion.py python
208 lines 7.4 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: user/identity account deletion (self-service + admin).
2
3 Self-service deletion already existed (DELETE /api/me, GDPR Article 17) but
4 had zero integration test coverage and no CLI/MCP wrapper. Admin-initiated
5 deletion of a *different* account did not exist at all -- there was no way
6 to remove a throwaway/spam/test account that wasn't the caller's own.
7
8 Both paths now share one effect via musehub_account.delete_identity_account:
9 hard-delete all registered auth keys, soft-delete the identity
10 (deleted_at set). This file covers both paths through the real routes.
11
12 Test IDs:
13 AD_01 — self-delete (DELETE /api/me) soft-deletes identity + hard-deletes keys
14 AD_02 — admin-delete (DELETE /api/admin/users/{handle}) does the same for another account
15 AD_03 — non-admin caller gets 403 on the admin path
16 AD_04 — unknown handle on the admin path gets 404
17 AD_05 — already-deleted handle on the admin path gets 404 (not re-deletable)
18 AD_06 — admin cannot be blocked from deleting an account via the self path's auth shape
19 (sanity: admin path works regardless of caller's own identity existing)
20 """
21 from __future__ import annotations
22
23 import hashlib as _hashlib
24 from datetime import datetime, timezone
25
26 import pytest
27 from httpx import AsyncClient
28 from sqlalchemy import select
29 from sqlalchemy.ext.asyncio import AsyncSession
30
31 from muse.core.types import encode_pubkey, long_id
32 from musehub.auth.request_signing import MSignContext, require_signed_request
33 from musehub.core.genesis import compute_identity_id, compute_key_id
34 from musehub.crypto.keys import key_fingerprint
35 from musehub.db.musehub_auth_models import MusehubAuthKey
36 from musehub.db.musehub_identity_models import MusehubIdentity
37 from musehub.main import app
38
39 _NOW = datetime.now(timezone.utc)
40 _COUNTER: list[int] = [0]
41
42
43 def _uid(tag: str = "") -> str:
44 _COUNTER[0] += 1
45 return f"ad{tag}{_COUNTER[0]}"
46
47
48 def _key_for(handle: str) -> str:
49 raw = _hashlib.sha256(handle.encode()).digest()
50 return encode_pubkey("ed25519", raw)
51
52
53 def _make_ctx(
54 is_admin: bool = False, handle: str = "actor", identity_id: str | None = None
55 ) -> MSignContext:
56 return MSignContext(
57 identity_id=identity_id or long_id(handle.ljust(64, "0")[:64]),
58 handle=handle,
59 is_agent=False,
60 is_admin=is_admin,
61 )
62
63
64 async def _seed_identity_with_key(session: AsyncSession, handle: str) -> MusehubIdentity:
65 import base64
66
67 pubkey_b64 = _key_for(handle)
68 identity_id = compute_identity_id(handle.encode())
69 identity = MusehubIdentity(
70 identity_id=identity_id,
71 handle=handle,
72 identity_type="human",
73 is_admin=False,
74 agent_capabilities=[],
75 pinned_repo_ids=[],
76 is_verified=False,
77 created_at=_NOW,
78 updated_at=_NOW,
79 )
80 session.add(identity)
81 await session.flush()
82
83 _, raw_b64 = pubkey_b64.split(":", 1)
84 raw_bytes = base64.urlsafe_b64decode(raw_b64 + "==")
85 fp = key_fingerprint(raw_bytes)
86 key = MusehubAuthKey(
87 key_id=compute_key_id(identity_id, pubkey_b64),
88 identity_id=identity_id,
89 algorithm="ed25519",
90 public_key_b64=pubkey_b64,
91 fingerprint=fp,
92 label="test-key",
93 created_at=_NOW,
94 )
95 session.add(key)
96 await session.flush()
97 return identity
98
99
100 async def _key_count(session: AsyncSession, identity_id: str) -> int:
101 rows = (await session.execute(
102 select(MusehubAuthKey).where(MusehubAuthKey.identity_id == identity_id)
103 )).scalars().all()
104 return len(rows)
105
106
107 # ── AD_01 — self-delete ──────────────────────────────────────────────────────
108
109
110 @pytest.mark.asyncio
111 async def test_ad01_self_delete_soft_deletes_identity_and_hard_deletes_keys(
112 client: AsyncClient, db_session: AsyncSession
113 ) -> None:
114 handle = f"selfdel-{_uid()}"
115 identity = await _seed_identity_with_key(db_session, handle)
116 await db_session.commit()
117 identity_id = identity.identity_id
118
119 app.dependency_overrides[require_signed_request] = lambda: _make_ctx(
120 is_admin=False, handle=handle, identity_id=identity_id
121 )
122 try:
123 resp = await client.delete("/api/me")
124 assert resp.status_code == 204, resp.text
125 finally:
126 app.dependency_overrides.pop(require_signed_request, None)
127
128 await db_session.refresh(identity)
129 assert identity.deleted_at is not None
130 assert await _key_count(db_session, identity_id) == 0
131
132
133 # ── AD_02 — admin-delete another account ─────────────────────────────────────
134
135
136 @pytest.mark.asyncio
137 async def test_ad02_admin_can_delete_a_different_account(
138 client: AsyncClient, db_session: AsyncSession
139 ) -> None:
140 handle = f"target-{_uid()}"
141 target = await _seed_identity_with_key(db_session, handle)
142 await db_session.commit()
143 identity_id = target.identity_id
144
145 app.dependency_overrides[require_signed_request] = lambda: _make_ctx(
146 is_admin=True, handle="admin-actor"
147 )
148 try:
149 resp = await client.delete(f"/api/admin/users/{handle}")
150 assert resp.status_code == 204, resp.text
151 finally:
152 app.dependency_overrides.pop(require_signed_request, None)
153
154 await db_session.refresh(target)
155 assert target.deleted_at is not None
156 assert await _key_count(db_session, identity_id) == 0
157
158
159 # ── AD_03 — non-admin forbidden ───────────────────────────────────────────────
160
161
162 @pytest.mark.asyncio
163 async def test_ad03_non_admin_gets_403(client: AsyncClient, db_session: AsyncSession) -> None:
164 handle = f"target-{_uid()}"
165 await _seed_identity_with_key(db_session, handle)
166 await db_session.commit()
167
168 app.dependency_overrides[require_signed_request] = lambda: _make_ctx(
169 is_admin=False, handle="not-an-admin"
170 )
171 try:
172 resp = await client.delete(f"/api/admin/users/{handle}")
173 assert resp.status_code == 403, resp.text
174 finally:
175 app.dependency_overrides.pop(require_signed_request, None)
176
177
178 # ── AD_04 — unknown handle ────────────────────────────────────────────────────
179
180
181 @pytest.mark.asyncio
182 async def test_ad04_unknown_handle_gets_404(client: AsyncClient) -> None:
183 app.dependency_overrides[require_signed_request] = lambda: _make_ctx(is_admin=True)
184 try:
185 resp = await client.delete(f"/api/admin/users/ghost-{_uid()}")
186 assert resp.status_code == 404, resp.text
187 finally:
188 app.dependency_overrides.pop(require_signed_request, None)
189
190
191 # ── AD_05 — already-deleted handle is not re-deletable ───────────────────────
192
193
194 @pytest.mark.asyncio
195 async def test_ad05_already_deleted_handle_gets_404(
196 client: AsyncClient, db_session: AsyncSession
197 ) -> None:
198 handle = f"target-{_uid()}"
199 target = await _seed_identity_with_key(db_session, handle)
200 target.deleted_at = _NOW
201 await db_session.commit()
202
203 app.dependency_overrides[require_signed_request] = lambda: _make_ctx(is_admin=True)
204 try:
205 resp = await client.delete(f"/api/admin/users/{handle}")
206 assert resp.status_code == 404, resp.text
207 finally:
208 app.dependency_overrides.pop(require_signed_request, None)
File History 1 commit
sha256:cb80b9ff286cfe86683166a3a87b9e154cbd890cc4f53071288633672e7df333 feat: full CRUD for user/identity -- admin delete, CLI/MCP … Sonnet 5 minor ⚠ 2 days ago