gabriel / musehub public
test_musehub_auth_challenge_db.py python
243 lines 8.4 KB
Raw
sha256:9590cee1e0ccd6c76528f005b95d634d80f5019f0dcb7c371e149adc31d1fb65 refactor: enforce gRPC framing on all MWP wire traffic Sonnet 4.6 minor ⚠ breaking 156 days ago
1 """Regression + integration tests for DB-backed auth challenge storage.
2
3 These tests verify that:
4 1. Challenges are persisted to Postgres (not in-memory).
5 2. A challenge created by one service instance is usable by another (blue-green
6 deploy resilience) — simulated here by using separate DB sessions.
7 3. Challenges are consumed (deleted) on first use — replay is prevented.
8 4. Expired challenges are rejected.
9 5. Unknown (never-issued) challenge tokens are rejected.
10
11 The in-memory dict (_pending_challenges) no longer exists in the codebase.
12 All state lives in the musehub_auth_challenges table.
13 """
14 from __future__ import annotations
15
16 import base64
17 import hashlib
18 import time
19 from datetime import datetime, timedelta, timezone
20
21 import pytest
22 from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
23 from httpx import AsyncClient
24 from sqlalchemy import select
25 from sqlalchemy.ext.asyncio import AsyncSession
26
27 from musehub.db.musehub_auth_models import MusehubAuthChallenge
28
29
30 # ---------------------------------------------------------------------------
31 # Helpers
32 # ---------------------------------------------------------------------------
33
34
35 def _b64url(b: bytes) -> str:
36 return base64.urlsafe_b64encode(b).rstrip(b"=").decode()
37
38
39 def _fp(raw: bytes) -> str:
40 return hashlib.sha256(raw).hexdigest()
41
42
43 def _kp() -> tuple[Ed25519PrivateKey, bytes, str, str]:
44 """Generate (priv, raw_pub, pub_b64, fingerprint)."""
45 priv = Ed25519PrivateKey.generate()
46 raw = priv.public_key().public_bytes_raw()
47 return priv, raw, _b64url(raw), _fp(raw)
48
49
50 def _sign(priv: Ed25519PrivateKey, nonce_hex: str) -> str:
51 return _b64url(priv.sign(bytes.fromhex(nonce_hex)))
52
53
54 # ---------------------------------------------------------------------------
55 # Tests
56 # ---------------------------------------------------------------------------
57
58
59 async def test_challenge_stored_in_db(
60 client: AsyncClient,
61 db_session: AsyncSession,
62 ) -> None:
63 """POST /api/auth/challenge must insert a row into musehub_auth_challenges."""
64 _, _, pub_b64, fp = _kp()
65
66 resp = await client.post("/api/auth/challenge", json={"fingerprint": fp})
67 assert resp.status_code == 200, resp.text
68
69 challenge_token = resp.json()["challenge_token"]
70
71 # The row must exist in the DB — not in any in-memory dict.
72 row: MusehubAuthChallenge | None = (
73 await db_session.execute(
74 select(MusehubAuthChallenge).where(
75 MusehubAuthChallenge.nonce_hex == challenge_token
76 )
77 )
78 ).scalar_one_or_none()
79
80 assert row is not None, "challenge row was not written to the database"
81 assert row.fingerprint == fp
82 assert row.expires_at > datetime.now(timezone.utc)
83
84
85 async def test_challenge_consumed_on_successful_verify(
86 client: AsyncClient,
87 db_session: AsyncSession,
88 ) -> None:
89 """After a successful verify, the challenge row must be deleted (single-use)."""
90 priv, _, pub_b64, fp = _kp()
91
92 r1 = await client.post("/api/auth/challenge", json={"fingerprint": fp})
93 assert r1.status_code == 200
94 ct = r1.json()["challenge_token"]
95
96 # Confirm row is in DB before verify
97 before: MusehubAuthChallenge | None = (
98 await db_session.execute(
99 select(MusehubAuthChallenge).where(MusehubAuthChallenge.nonce_hex == ct)
100 )
101 ).scalar_one_or_none()
102 assert before is not None
103
104 r2 = await client.post("/api/auth/verify", json={
105 "challenge_token": ct,
106 "public_key_b64": pub_b64,
107 "signature_b64": _sign(priv, ct),
108 "handle": "testhandle-consumed",
109 })
110 assert r2.status_code == 200, r2.text
111
112 # Expire the session cache so we see the committed state
113 db_session.expire_all()
114
115 # Row must be gone
116 after: MusehubAuthChallenge | None = (
117 await db_session.execute(
118 select(MusehubAuthChallenge).where(MusehubAuthChallenge.nonce_hex == ct)
119 )
120 ).scalar_one_or_none()
121 assert after is None, "challenge row was not deleted after successful verify"
122
123
124 async def test_challenge_replay_rejected(
125 client: AsyncClient,
126 db_session: AsyncSession,
127 ) -> None:
128 """Re-submitting a consumed challenge token must return 400."""
129 priv, _, pub_b64, fp = _kp()
130
131 r1 = await client.post("/api/auth/challenge", json={"fingerprint": fp})
132 assert r1.status_code == 200
133 ct = r1.json()["challenge_token"]
134 sig = _sign(priv, ct)
135
136 # First use — should succeed
137 r2 = await client.post("/api/auth/verify", json={
138 "challenge_token": ct,
139 "public_key_b64": pub_b64,
140 "signature_b64": sig,
141 "handle": "replay-test-handle",
142 })
143 assert r2.status_code == 200, r2.text
144
145 # Replay — must be rejected because the row was deleted
146 r3 = await client.post("/api/auth/verify", json={
147 "challenge_token": ct,
148 "public_key_b64": pub_b64,
149 "signature_b64": sig,
150 "handle": "replay-test-handle",
151 })
152 assert r3.status_code == 400, f"replay was not rejected: {r3.text}"
153 assert "expired" in r3.json()["detail"].lower() or "unknown" in r3.json()["detail"].lower()
154
155
156 async def test_unknown_challenge_token_rejected(
157 client: AsyncClient,
158 db_session: AsyncSession,
159 ) -> None:
160 """A challenge token that was never issued must return 400."""
161 priv, _, pub_b64, fp = _kp()
162 # Fabricate a plausible 64-hex nonce that was never inserted
163 fake_token = "deadbeef" * 8 # 64 hex chars
164
165 r = await client.post("/api/auth/verify", json={
166 "challenge_token": fake_token,
167 "public_key_b64": pub_b64,
168 "signature_b64": _sign(priv, fake_token),
169 "handle": "unknown-token-handle",
170 })
171 assert r.status_code == 400, r.text
172
173
174 async def test_expired_challenge_rejected(
175 client: AsyncClient,
176 db_session: AsyncSession,
177 ) -> None:
178 """A challenge whose expires_at is in the past must be rejected."""
179 priv, _, pub_b64, fp = _kp()
180 import secrets
181
182 # Insert an already-expired challenge directly into the DB
183 nonce_hex = secrets.token_bytes(32).hex()
184 expired_row = MusehubAuthChallenge(
185 nonce_hex=nonce_hex,
186 fingerprint=fp,
187 algorithm="ed25519",
188 expires_at=datetime.now(timezone.utc) - timedelta(seconds=1),
189 )
190 db_session.add(expired_row)
191 await db_session.commit()
192
193 r = await client.post("/api/auth/verify", json={
194 "challenge_token": nonce_hex,
195 "public_key_b64": pub_b64,
196 "signature_b64": _sign(priv, nonce_hex),
197 "handle": "expired-handle",
198 })
199 assert r.status_code == 400, r.text
200 detail = r.json()["detail"].lower()
201 assert "expired" in detail or "unknown" in detail
202
203
204 async def test_blue_green_resilience(
205 client: AsyncClient,
206 db_session: AsyncSession,
207 ) -> None:
208 """Challenge created in one 'process' is verifiable in another.
209
210 This simulates a blue-green deploy: the green container issues a challenge,
211 then nginx flips to blue. Blue has no in-memory state — but both share
212 Postgres. The verify call must succeed.
213
214 Simulation: use a fresh AsyncSession (separate from db_session) to call
215 create_challenge, then verify via the HTTP client (which uses its own session).
216 If challenges were in-memory, this would fail because the HTTP handler's
217 session would find no matching nonce. Since they are in Postgres, it works.
218 """
219 from musehub.db import database as _db # noqa: PLC0415
220 from musehub.services.musehub_auth import create_challenge as _create_challenge # noqa: PLC0415
221
222 priv, _, pub_b64, fp = _kp()
223
224 # Simulate "green" container creating the challenge in its own session.
225 # _db._async_session_factory is pointed at the test DB by the db_session fixture.
226 async with _db._async_session_factory() as green_session:
227 nonce_hex = await _create_challenge(green_session, fp, "ed25519")
228
229 # Simulate "blue" container verifying — uses a completely separate session
230 # (the HTTP client gets its own session via the override_get_db fixture)
231 r = await client.post("/api/auth/verify", json={
232 "challenge_token": nonce_hex,
233 "public_key_b64": pub_b64,
234 "signature_b64": _sign(priv, nonce_hex),
235 "handle": "blue-green-handle",
236 })
237 assert r.status_code == 200, (
238 f"Blue-green resilience FAILED — challenge created in one session could not "
239 f"be verified in another: {r.text}"
240 )
241 data = r.json()
242 assert data["handle"] == "blue-green-handle"
243 assert data["is_new_identity"] is True
File History 1 commit
sha256:9590cee1e0ccd6c76528f005b95d634d80f5019f0dcb7c371e149adc31d1fb65 refactor: enforce gRPC framing on all MWP wire traffic Sonnet 4.6 minor ⚠ 156 days ago