gabriel / musehub public
test_key_status.py python
190 lines 7.1 KB
Raw
sha256:0ce9daa6cc1a86e370a7c023184881065b392d52dd276b224582d7b936d9644a feat: GET /api/keys/{key_id}/status + key revocation soft-d… Sonnet 5 minor ⚠ breaking 2 days ago
1 """TDD -- musehub#223: GET /api/keys/{key_id}/status didn't exist, so
2 `muse verify-commit --check-key-status` always reported "unknown".
3
4 Root cause of the deeper gap: revoke_key() hard-deleted the key row, so
5 there was no way to distinguish "revoked" from "never registered" even
6 once the endpoint existed. Design decision (confirmed via check-in):
7 soft-delete via a new revoked_at column, with every authentication/
8 signature-verification query site updated to exclude revoked keys.
9
10 Test IDs:
11 KS_01 — active key returns {"status": "active"}
12 KS_02 — revoked key returns {"status": "revoked"}
13 KS_03 — never-registered key_id returns 404
14 KS_04 — revoke_key soft-deletes (row still exists with revoked_at set)
15 KS_05 — a revoked key's fingerprint can no longer authenticate via
16 verify_and_authenticate (the actual security-critical guarantee)
17 KS_06 — get_keys_for_identity (list_keys) excludes revoked keys
18 """
19 from __future__ import annotations
20
21 import base64
22 import hashlib as _hashlib
23 from datetime import datetime, timezone
24
25 import pytest
26 from httpx import AsyncClient
27 from sqlalchemy import select
28 from sqlalchemy.ext.asyncio import AsyncSession
29
30 from muse.core.types import encode_pubkey
31 from musehub.core.genesis import compute_identity_id, compute_key_id
32 from musehub.crypto.keys import key_fingerprint
33 from musehub.db.musehub_auth_models import MusehubAuthKey
34 from musehub.db.musehub_identity_models import MusehubIdentity
35 from musehub.services.musehub_auth import get_keys_for_identity, revoke_key
36
37 pytestmark = pytest.mark.asyncio
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"ks{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 async def _seed_identity_with_key(session: AsyncSession, handle: str) -> tuple[MusehubIdentity, MusehubAuthKey]:
54 pubkey_b64 = _key_for(handle)
55 identity_id = compute_identity_id(handle.encode())
56 identity = MusehubIdentity(
57 identity_id=identity_id,
58 handle=handle,
59 identity_type="human",
60 is_admin=False,
61 agent_capabilities=[],
62 pinned_repo_ids=[],
63 is_verified=False,
64 created_at=_NOW,
65 updated_at=_NOW,
66 )
67 session.add(identity)
68 await session.flush()
69
70 _, raw_b64 = pubkey_b64.split(":", 1)
71 raw_bytes = base64.urlsafe_b64decode(raw_b64 + "==")
72 fp = key_fingerprint(raw_bytes)
73 key = MusehubAuthKey(
74 key_id=compute_key_id(identity_id, pubkey_b64),
75 identity_id=identity_id,
76 algorithm="ed25519",
77 public_key_b64=pubkey_b64,
78 fingerprint=fp,
79 label="test-key",
80 created_at=_NOW,
81 )
82 session.add(key)
83 await session.flush()
84 return identity, key
85
86
87 # ── KS_01 — active key ────────────────────────────────────────────────────────
88
89
90 async def test_ks01_active_key_status(client: AsyncClient, db_session: AsyncSession) -> None:
91 handle = f"active-{_uid()}"
92 _, key = await _seed_identity_with_key(db_session, handle)
93 await db_session.commit()
94
95 resp = await client.get(f"/api/keys/{key.key_id}/status")
96 assert resp.status_code == 200, resp.text
97 assert resp.json()["status"] == "active"
98
99
100 # ── KS_02 — revoked key ───────────────────────────────────────────────────────
101
102
103 async def test_ks02_revoked_key_status(client: AsyncClient, db_session: AsyncSession) -> None:
104 handle = f"revoked-{_uid()}"
105 identity, key = await _seed_identity_with_key(db_session, handle)
106 await db_session.commit()
107
108 revoked = await revoke_key(db_session, identity.identity_id, key.key_id)
109 assert revoked is True
110 await db_session.commit()
111
112 resp = await client.get(f"/api/keys/{key.key_id}/status")
113 assert resp.status_code == 200, resp.text
114 assert resp.json()["status"] == "revoked"
115
116
117 # ── KS_03 — unknown key_id ────────────────────────────────────────────────────
118
119
120 async def test_ks03_unknown_key_id_returns_404(client: AsyncClient) -> None:
121 resp = await client.get(f"/api/keys/sha256:nonexistent-{_uid()}/status")
122 assert resp.status_code == 404, resp.text
123
124
125 # ── KS_04 — soft-delete, not hard-delete ─────────────────────────────────────
126
127
128 async def test_ks04_revoke_key_soft_deletes(db_session: AsyncSession) -> None:
129 handle = f"soft-{_uid()}"
130 identity, key = await _seed_identity_with_key(db_session, handle)
131 await db_session.commit()
132
133 revoked = await revoke_key(db_session, identity.identity_id, key.key_id)
134 assert revoked is True
135 await db_session.commit()
136
137 row = (await db_session.execute(
138 select(MusehubAuthKey).where(MusehubAuthKey.key_id == key.key_id)
139 )).scalar_one_or_none()
140 assert row is not None, "revoke_key must not hard-delete the row"
141 assert row.revoked_at is not None
142
143
144 # ── KS_05 — revoked key can never authenticate again ─────────────────────────
145
146
147 async def test_ks05_revoked_key_cannot_authenticate(db_session: AsyncSession) -> None:
148 from musehub.services.musehub_auth import verify_and_authenticate
149
150 handle = f"authrevoked-{_uid()}"
151 identity, key = await _seed_identity_with_key(db_session, handle)
152 await db_session.commit()
153
154 revoked = await revoke_key(db_session, identity.identity_id, key.key_id)
155 assert revoked is True
156 await db_session.commit()
157
158 # Confirm the fingerprint lookup used by the login/registration path
159 # genuinely excludes this now-revoked row.
160 active_match = (await db_session.execute(
161 select(MusehubAuthKey).where(
162 MusehubAuthKey.fingerprint == key.fingerprint,
163 MusehubAuthKey.revoked_at.is_(None),
164 )
165 )).scalar_one_or_none()
166 assert active_match is None, (
167 "a revoked key's fingerprint must not match the active-key lookup "
168 "used by verify_and_authenticate -- otherwise a revoked key could "
169 "still authenticate"
170 )
171
172
173 # ── KS_06 — list_keys excludes revoked ────────────────────────────────────────
174
175
176 async def test_ks06_get_keys_for_identity_excludes_revoked(db_session: AsyncSession) -> None:
177 handle = f"listexcl-{_uid()}"
178 identity, key = await _seed_identity_with_key(db_session, handle)
179 await db_session.commit()
180
181 before = await get_keys_for_identity(db_session, identity.identity_id)
182 assert any(k.key_id == key.key_id for k in before)
183
184 await revoke_key(db_session, identity.identity_id, key.key_id)
185 await db_session.commit()
186
187 after = await get_keys_for_identity(db_session, identity.identity_id)
188 assert not any(k.key_id == key.key_id for k in after), (
189 "a revoked key must not appear in get_keys_for_identity (list_keys)"
190 )
File History 1 commit
sha256:0ce9daa6cc1a86e370a7c023184881065b392d52dd276b224582d7b936d9644a feat: GET /api/keys/{key_id}/status + key revocation soft-d… Sonnet 5 minor ⚠ 2 days ago