gabriel / musehub public
test_identity_integration.py python
376 lines 13.6 KB
Raw
sha256:a10adeeb7a0169cb9900f9806ed7a973047258abb6283724fe55e8eb68ff3f0a init: musehub initial commit Human 171 days ago
1 """Integration tests for Phase 3: Auth as Code.
2
3 Tests:
4 - Expired identity returns 401 at auth time
5 - Agent identity scope is propagated to MSignContext
6 - require_scope() grants access to matching-scope agents
7 - require_scope() blocks agents missing the required scope
8 - Human identities (scope=None) bypass scope checks unconditionally
9 - require_scope() blocks with 403 (not 401) on scope failure
10 - 403 response includes the required scope name in detail
11
12 Run targeted:
13 docker compose exec musehub pytest tests/test_identity_integration.py -v
14 """
15 from __future__ import annotations
16
17 import time
18 import uuid
19 from datetime import datetime, timedelta, timezone
20 from unittest.mock import AsyncMock, MagicMock
21
22 import pytest
23 from fastapi import HTTPException
24 from httpx import AsyncClient
25 from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
26 from sqlalchemy.ext.asyncio import AsyncSession
27
28 from musehub.auth.dependencies import require_scope as dep_require_scope, TokenClaims
29 from musehub.auth.request_signing import MSignContext, _verify_msign, build_canonical_message, require_scope
30 from musehub.crypto.keys import b64url_encode, key_fingerprint
31 from musehub.db import musehub_models as db
32 from musehub.db.musehub_auth_models import MusehubAuthKey
33
34
35 # ---------------------------------------------------------------------------
36 # Helpers
37 # ---------------------------------------------------------------------------
38
39 def _uid() -> str:
40 return str(uuid.uuid4())
41
42
43 def _keypair() -> tuple[Ed25519PrivateKey, bytes]:
44 priv = Ed25519PrivateKey.generate()
45 pub = priv.public_key().public_bytes_raw()
46 return priv, pub
47
48
49 def _msign_header(
50 priv: Ed25519PrivateKey,
51 handle: str,
52 method: str,
53 path: str,
54 body: bytes = b"",
55 ts: int | None = None,
56 ) -> str:
57 ts = ts if ts is not None else int(time.time())
58 canonical = build_canonical_message(method, path, ts, body)
59 sig_bytes = priv.sign(canonical)
60 sig_b64 = b64url_encode(sig_bytes)
61 return f'MSign handle="{handle}" ts={ts} sig="{sig_b64}"'
62
63
64 async def _seed_identity(
65 session: AsyncSession,
66 handle: str,
67 priv: Ed25519PrivateKey,
68 pub: bytes,
69 *,
70 identity_type: str = "human",
71 scope: list[str] | None = None,
72 expires_at: datetime | None = None,
73 ) -> db.MusehubIdentity:
74 """Create a MusehubIdentity + MusehubAuthKey pair in the test DB."""
75 identity = db.MusehubIdentity(
76 id=_uid(),
77 handle=handle,
78 identity_type=identity_type,
79 display_name=handle,
80 scope=scope,
81 expires_at=expires_at,
82 )
83 session.add(identity)
84 await session.flush()
85
86 key_row = MusehubAuthKey(
87 key_id=_uid(),
88 identity_id=identity.id,
89 algorithm="ed25519",
90 public_key_b64=b64url_encode(pub),
91 fingerprint=key_fingerprint(pub),
92 label="test-key",
93 )
94 session.add(key_row)
95 await session.commit()
96 await session.refresh(identity)
97 return identity
98
99
100 # ---------------------------------------------------------------------------
101 # 1. Expiry enforcement (E2E via HTTP client)
102 # ---------------------------------------------------------------------------
103
104
105 async def test_expired_agent_returns_401(client: AsyncClient, db_session: AsyncSession) -> None:
106 """An expired identity is rejected with 401 regardless of key validity."""
107 priv, pub = _keypair()
108 handle = "expired-bot-" + _uid()[:8]
109 await _seed_identity(
110 db_session, handle, priv, pub,
111 identity_type="agent",
112 expires_at=datetime.now(timezone.utc) - timedelta(hours=1),
113 )
114 auth = _msign_header(priv, handle, "GET", "/api/repos")
115 resp = await client.get("/api/repos", headers={"Authorization": auth})
116 assert resp.status_code == 401
117 assert "expired" in resp.json().get("detail", "").lower()
118
119
120 async def test_not_yet_expired_agent_passes_auth(client: AsyncClient, db_session: AsyncSession) -> None:
121 """An agent whose expires_at is in the future passes the auth check."""
122 priv, pub = _keypair()
123 handle = "fresh-bot-" + _uid()[:8]
124 await _seed_identity(
125 db_session, handle, priv, pub,
126 identity_type="agent",
127 scope=["issue:write"],
128 expires_at=datetime.now(timezone.utc) + timedelta(hours=2),
129 )
130 auth = _msign_header(priv, handle, "GET", "/api/repos")
131 resp = await client.get("/api/repos", headers={"Authorization": auth})
132 # Any non-401 means the auth check passed (could be 200 or scope-gated 403)
133 assert resp.status_code != 401
134
135
136 async def test_human_without_expiry_passes_auth(client: AsyncClient, db_session: AsyncSession) -> None:
137 """Human identities with no expires_at are not rejected."""
138 priv, pub = _keypair()
139 handle = "human-noexp-" + _uid()[:8]
140 await _seed_identity(db_session, handle, priv, pub, identity_type="human")
141 auth = _msign_header(priv, handle, "GET", "/api/repos")
142 resp = await client.get("/api/repos", headers={"Authorization": auth})
143 assert resp.status_code != 401
144
145
146 # ---------------------------------------------------------------------------
147 # 2. Scope propagation (service layer — _verify_msign directly)
148 # ---------------------------------------------------------------------------
149
150
151 async def test_agent_scope_propagated_to_msign_context(db_session: AsyncSession) -> None:
152 """scope list from MusehubIdentity is set on MSignContext after verification."""
153 priv, pub = _keypair()
154 handle = "scoped-agent-" + _uid()[:8]
155 await _seed_identity(
156 db_session, handle, priv, pub,
157 identity_type="agent",
158 scope=["issue:write", "proposal:write"],
159 )
160
161 method = "GET"
162 path = "/test"
163 ts = int(time.time())
164 canonical = build_canonical_message(method, path, ts, b"")
165 sig_bytes = priv.sign(canonical)
166 sig_b64 = b64url_encode(sig_bytes)
167 auth_header = f'MSign handle="{handle}" ts={ts} sig="{sig_b64}"'
168
169 request = MagicMock()
170 request.headers.get.return_value = auth_header
171 request.method = method
172 request.url.path = path
173 request.url.query = ""
174 request.body = AsyncMock(return_value=b"")
175
176 ctx = await _verify_msign(request, db_session, required=True)
177 assert ctx is not None
178 assert ctx.scope == ["issue:write", "proposal:write"]
179 assert ctx.is_agent is True
180
181
182 async def test_human_scope_is_none_in_context(db_session: AsyncSession) -> None:
183 """Human identity with no scope column → MSignContext.scope is None."""
184 priv, pub = _keypair()
185 handle = "human-noscope-" + _uid()[:8]
186 await _seed_identity(db_session, handle, priv, pub, identity_type="human")
187
188 method = "GET"
189 path = "/test"
190 ts = int(time.time())
191 canonical = build_canonical_message(method, path, ts, b"")
192 sig_bytes = priv.sign(canonical)
193 sig_b64 = b64url_encode(sig_bytes)
194 auth_header = f'MSign handle="{handle}" ts={ts} sig="{sig_b64}"'
195
196 request = MagicMock()
197 request.headers.get.return_value = auth_header
198 request.method = method
199 request.url.path = path
200 request.url.query = ""
201 request.body = AsyncMock(return_value=b"")
202
203 ctx = await _verify_msign(request, db_session, required=True)
204 assert ctx is not None
205 assert ctx.scope is None
206 assert ctx.is_agent is False
207
208
209 # ---------------------------------------------------------------------------
210 # 3. require_scope() unit logic (pure — no DB needed)
211 # ---------------------------------------------------------------------------
212
213
214 async def test_require_scope_human_scope_none_passes() -> None:
215 """require_scope() passes when claims.scope is None (human identity)."""
216 ctx = MSignContext(handle="human", identity_id="x", is_agent=False, is_admin=False, scope=None)
217 inner_dep = dep_require_scope("issue:write")
218 result = await inner_dep(claims=ctx)
219 assert result is ctx
220
221
222 async def test_require_scope_agent_matching_scope_passes() -> None:
223 """require_scope() passes when agent scope contains the required value."""
224 ctx = MSignContext(
225 handle="bot", identity_id="x", is_agent=True, is_admin=False,
226 scope=["issue:write", "proposal:write"],
227 )
228 inner_dep = dep_require_scope("issue:write")
229 result = await inner_dep(claims=ctx)
230 assert result is ctx
231
232
233 async def test_require_scope_agent_missing_scope_raises_403() -> None:
234 """require_scope() raises HTTP 403 when agent scope lacks the required value."""
235 ctx = MSignContext(
236 handle="bot", identity_id="x", is_agent=True, is_admin=False,
237 scope=["label:read"],
238 )
239 inner_dep = dep_require_scope("issue:write")
240 with pytest.raises(HTTPException) as exc_info:
241 await inner_dep(claims=ctx)
242 assert exc_info.value.status_code == 403
243 assert "issue:write" in exc_info.value.detail
244
245
246 async def test_require_scope_empty_scope_list_blocks_everything() -> None:
247 """Agent with scope=[] (empty list) is blocked from any scoped operation."""
248 ctx = MSignContext(
249 handle="bot", identity_id="x", is_agent=True, is_admin=False,
250 scope=[],
251 )
252 for required in ("issue:write", "proposal:write", "label:read", "release:write"):
253 inner_dep = dep_require_scope(required)
254 with pytest.raises(HTTPException) as exc_info:
255 await inner_dep(claims=ctx)
256 assert exc_info.value.status_code == 403
257
258
259 async def test_require_scope_returns_callable() -> None:
260 """require_scope() factory returns an awaitable callable."""
261 import asyncio
262 dep = require_scope("issue:write")
263 assert asyncio.iscoroutinefunction(dep)
264
265
266 # ---------------------------------------------------------------------------
267 # 4. Scope enforcement via HTTP (route-level)
268 # ---------------------------------------------------------------------------
269
270
271 async def test_agent_missing_issue_write_scope_gets_403_on_issue_create(
272 client: AsyncClient, db_session: AsyncSession
273 ) -> None:
274 """Agent without issue:write scope receives 403 when creating an issue."""
275 import json as _json
276 from tests.factories import create_repo
277
278 priv, pub = _keypair()
279 handle = "bot-no-issue-" + _uid()[:8]
280 await _seed_identity(
281 db_session, handle, priv, pub,
282 identity_type="agent",
283 scope=["label:read"], # intentionally missing issue:write
284 )
285 repo = await create_repo(db_session, owner=handle, visibility="public")
286
287 path = f"/api/repos/{repo.repo_id}/issues"
288 request_body = _json.dumps({"title": "Forbidden", "body": "body"}).encode()
289 auth = _msign_header(priv, handle, "POST", path, body=request_body)
290 resp = await client.post(
291 path,
292 content=request_body,
293 headers={"Authorization": auth, "Content-Type": "application/json"},
294 )
295 assert resp.status_code == 403
296 assert "issue:write" in resp.json().get("detail", "")
297
298
299 async def test_agent_with_issue_write_scope_passes_scope_check(
300 client: AsyncClient, db_session: AsyncSession
301 ) -> None:
302 """Agent with issue:write scope is not rejected by scope check on issue creation."""
303 import json as _json
304 from tests.factories import create_repo
305
306 priv, pub = _keypair()
307 handle = "bot-issue-w-" + _uid()[:8]
308 await _seed_identity(
309 db_session, handle, priv, pub,
310 identity_type="agent",
311 scope=["issue:write", "issue:read"],
312 )
313 repo = await create_repo(db_session, owner=handle, visibility="public")
314
315 path = f"/api/repos/{repo.repo_id}/issues"
316 request_body = _json.dumps({"title": "Agent issue", "body": "agent body"}).encode()
317 auth = _msign_header(priv, handle, "POST", path, body=request_body)
318 resp = await client.post(
319 path,
320 content=request_body,
321 headers={"Authorization": auth, "Content-Type": "application/json"},
322 )
323 # Scope check passes → 201 created (or a validation error, but NOT 403)
324 assert resp.status_code != 403
325
326
327 async def test_agent_missing_proposal_write_scope_gets_403(
328 client: AsyncClient, db_session: AsyncSession
329 ) -> None:
330 """Agent without proposal:write scope receives 403 when creating a proposal."""
331 import json as _json
332 from tests.factories import create_repo
333
334 priv, pub = _keypair()
335 handle = "bot-no-prop-" + _uid()[:8]
336 await _seed_identity(
337 db_session, handle, priv, pub,
338 identity_type="agent",
339 scope=["issue:write", "issue:read"], # no proposal:write
340 )
341 repo = await create_repo(db_session, owner=handle, visibility="public")
342
343 path = f"/api/repos/{repo.repo_id}/proposals"
344 request_body = _json.dumps({"title": "Test", "from_branch": "feat/x", "to_branch": "dev"}).encode()
345 auth = _msign_header(priv, handle, "POST", path, body=request_body)
346 resp = await client.post(
347 path,
348 content=request_body,
349 headers={"Authorization": auth, "Content-Type": "application/json"},
350 )
351 assert resp.status_code == 403
352 assert "proposal:write" in resp.json().get("detail", "")
353
354
355 async def test_human_passes_scope_check_on_issue_create(
356 client: AsyncClient, db_session: AsyncSession
357 ) -> None:
358 """Human identity (scope=None) bypasses scope enforcement and can create issues."""
359 import json as _json
360 from tests.factories import create_repo
361
362 priv, pub = _keypair()
363 handle = "human-full-" + _uid()[:8]
364 await _seed_identity(db_session, handle, priv, pub, identity_type="human")
365 repo = await create_repo(db_session, owner=handle, visibility="public")
366
367 path = f"/api/repos/{repo.repo_id}/issues"
368 request_body = _json.dumps({"title": "Human issue", "body": "body"}).encode()
369 auth = _msign_header(priv, handle, "POST", path, body=request_body)
370 resp = await client.post(
371 path,
372 content=request_body,
373 headers={"Authorization": auth, "Content-Type": "application/json"},
374 )
375 # Human should not be blocked by scope check
376 assert resp.status_code != 403
File History 1 commit
sha256:a10adeeb7a0169cb9900f9806ed7a973047258abb6283724fe55e8eb68ff3f0a init: musehub initial commit Human 171 days ago