gabriel / musehub public
test_musehub_auth_challenge_db.py python
263 lines 9.2 KB
Raw
sha256:3ff9c9863a9891bdcde71b4a43228f66d0493e38b7cc1d09fe9eb7de774046b2 feat: add repair-commit wire endpoint (API parity with repa… Opus 4.8 minor ⚠ breaking 1 day 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 time
17
18 from muse.core.types import encode_pubkey, encode_sig, public_key_fingerprint, long_id
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 _kp() -> tuple[Ed25519PrivateKey, bytes, str, str]:
36 """Generate (priv, raw_pub, pub_b64, fingerprint)."""
37 priv = Ed25519PrivateKey.generate()
38 raw = priv.public_key().public_bytes_raw()
39 return priv, raw, encode_pubkey("ed25519", raw), public_key_fingerprint(raw)
40
41
42 def _sign(priv: Ed25519PrivateKey, nonce_hex: str) -> str:
43 return encode_sig("ed25519", priv.sign(bytes.fromhex(nonce_hex)))
44
45
46 # ---------------------------------------------------------------------------
47 # Tests
48 # ---------------------------------------------------------------------------
49
50
51 async def test_challenge_stored_in_db(
52 client: AsyncClient,
53 db_session: AsyncSession,
54 ) -> None:
55 """POST /api/auth/challenge must insert a row into musehub_auth_challenges."""
56 _, _, pub_b64, fp = _kp()
57
58 resp = await client.post("/api/auth/challenge", json={"fingerprint": fp})
59 assert resp.status_code == 200, resp.text
60
61 challenge_token = resp.json()["challenge_token"]
62
63 # The row must exist in the DB — not in any in-memory dict.
64 row: MusehubAuthChallenge | None = (
65 await db_session.execute(
66 select(MusehubAuthChallenge).where(
67 MusehubAuthChallenge.nonce_hex == challenge_token
68 )
69 )
70 ).scalar_one_or_none()
71
72 assert row is not None, "challenge row was not written to the database"
73 assert row.fingerprint == fp
74 assert row.expires_at > datetime.now(timezone.utc)
75
76
77 async def test_challenge_consumed_on_successful_verify(
78 client: AsyncClient,
79 db_session: AsyncSession,
80 ) -> None:
81 """After a successful verify, the challenge row must be deleted (single-use)."""
82 priv, _, pub_b64, fp = _kp()
83
84 r1 = await client.post("/api/auth/challenge", json={"fingerprint": fp})
85 assert r1.status_code == 200
86 ct = r1.json()["challenge_token"]
87
88 # Confirm row is in DB before verify
89 before: MusehubAuthChallenge | None = (
90 await db_session.execute(
91 select(MusehubAuthChallenge).where(MusehubAuthChallenge.nonce_hex == ct)
92 )
93 ).scalar_one_or_none()
94 assert before is not None
95
96 r2 = await client.post("/api/auth/verify", json={
97 "challenge_token": ct,
98 "public_key_b64": pub_b64,
99 "signature_b64": _sign(priv, ct),
100 "handle": "testhandle-consumed",
101 })
102 assert r2.status_code == 200, r2.text
103
104 # Expire the session cache so we see the committed state
105 db_session.expire_all()
106
107 # Row must be gone
108 after: MusehubAuthChallenge | None = (
109 await db_session.execute(
110 select(MusehubAuthChallenge).where(MusehubAuthChallenge.nonce_hex == ct)
111 )
112 ).scalar_one_or_none()
113 assert after is None, "challenge row was not deleted after successful verify"
114
115
116 async def test_challenge_replay_rejected(
117 client: AsyncClient,
118 db_session: AsyncSession,
119 ) -> None:
120 """Re-submitting a consumed challenge token must return 400."""
121 priv, _, pub_b64, fp = _kp()
122
123 r1 = await client.post("/api/auth/challenge", json={"fingerprint": fp})
124 assert r1.status_code == 200
125 ct = r1.json()["challenge_token"]
126 sig = _sign(priv, ct)
127
128 # First use — should succeed
129 r2 = await client.post("/api/auth/verify", json={
130 "challenge_token": ct,
131 "public_key_b64": pub_b64,
132 "signature_b64": sig,
133 "handle": "replay-test-handle",
134 })
135 assert r2.status_code == 200, r2.text
136
137 # Replay — must be rejected because the row was deleted
138 r3 = await client.post("/api/auth/verify", json={
139 "challenge_token": ct,
140 "public_key_b64": pub_b64,
141 "signature_b64": sig,
142 "handle": "replay-test-handle",
143 })
144 assert r3.status_code == 400, f"replay was not rejected: {r3.text}"
145 assert "expired" in r3.json()["detail"].lower() or "unknown" in r3.json()["detail"].lower()
146
147
148 async def test_unknown_challenge_token_rejected(
149 client: AsyncClient,
150 db_session: AsyncSession,
151 ) -> None:
152 """A challenge token that was never issued must return 400."""
153 priv, _, pub_b64, fp = _kp()
154 # Fabricate a plausible 64-hex nonce that was never inserted
155 fake_token = "deadbeef" * 8 # 64 hex chars
156
157 r = await client.post("/api/auth/verify", json={
158 "challenge_token": fake_token,
159 "public_key_b64": pub_b64,
160 "signature_b64": _sign(priv, fake_token),
161 "handle": "unknown-token-handle",
162 })
163 assert r.status_code == 400, r.text
164
165
166 async def test_expired_challenge_rejected(
167 client: AsyncClient,
168 db_session: AsyncSession,
169 ) -> None:
170 """A challenge whose expires_at is in the past must be rejected."""
171 priv, _, pub_b64, fp = _kp()
172 import secrets
173
174 # Insert an already-expired challenge directly into the DB
175 nonce_hex = secrets.token_bytes(32).hex()
176 expired_row = MusehubAuthChallenge(
177 nonce_hex=nonce_hex,
178 fingerprint=fp,
179 algorithm="ed25519",
180 expires_at=datetime.now(timezone.utc) - timedelta(seconds=1),
181 )
182 db_session.add(expired_row)
183 await db_session.commit()
184
185 r = await client.post("/api/auth/verify", json={
186 "challenge_token": nonce_hex,
187 "public_key_b64": pub_b64,
188 "signature_b64": _sign(priv, nonce_hex),
189 "handle": "expired-handle",
190 })
191 assert r.status_code == 400, r.text
192 detail = r.json()["detail"].lower()
193 assert "expired" in detail or "unknown" in detail
194
195
196 async def test_nonce_is_primary_key(db_session: AsyncSession) -> None:
197 """nonce_hex must be the PK — challenge_id must not exist on the model."""
198 import inspect
199 import secrets
200
201 # challenge_id must not exist
202 assert not hasattr(MusehubAuthChallenge, "challenge_id"), (
203 "challenge_id should have been dropped; nonce_hex is the PK now"
204 )
205
206 nonce_hex = secrets.token_bytes(32).hex()
207 row = MusehubAuthChallenge(
208 nonce_hex=nonce_hex,
209 fingerprint=long_id("a" * 64),
210 algorithm="ed25519",
211 expires_at=datetime.now(timezone.utc) + timedelta(minutes=5),
212 )
213 db_session.add(row)
214 await db_session.flush()
215
216 fetched = (
217 await db_session.execute(
218 select(MusehubAuthChallenge).where(MusehubAuthChallenge.nonce_hex == nonce_hex)
219 )
220 ).scalar_one()
221 assert fetched.nonce_hex == nonce_hex
222
223
224 async def test_blue_green_resilience(
225 client: AsyncClient,
226 db_session: AsyncSession,
227 ) -> None:
228 """Challenge created in one 'process' is verifiable in another.
229
230 This simulates a blue-green deploy: the green container issues a challenge,
231 then nginx flips to blue. Blue has no in-memory state — but both share
232 Postgres. The verify call must succeed.
233
234 Simulation: use a fresh AsyncSession (separate from db_session) to call
235 create_challenge, then verify via the HTTP client (which uses its own session).
236 If challenges were in-memory, this would fail because the HTTP handler's
237 session would find no matching nonce. Since they are in Postgres, it works.
238 """
239 from musehub.db import database as _db # noqa: PLC0415
240 from musehub.services.musehub_auth import create_challenge as _create_challenge # noqa: PLC0415
241
242 priv, _, pub_b64, fp = _kp()
243
244 # Simulate "green" container creating the challenge in its own session.
245 # _db._async_session_factory is pointed at the test DB by the db_session fixture.
246 async with _db._async_session_factory() as green_session:
247 nonce_hex = await _create_challenge(green_session, fp, "ed25519")
248
249 # Simulate "blue" container verifying — uses a completely separate session
250 # (the HTTP client gets its own session via the override_get_db fixture)
251 r = await client.post("/api/auth/verify", json={
252 "challenge_token": nonce_hex,
253 "public_key_b64": pub_b64,
254 "signature_b64": _sign(priv, nonce_hex),
255 "handle": "blue-green-handle",
256 })
257 assert r.status_code == 200, (
258 f"Blue-green resilience FAILED — challenge created in one session could not "
259 f"be verified in another: {r.text}"
260 )
261 data = r.json()
262 assert data["handle"] == "blue-green-handle"
263 assert data["is_new_identity"] is True
File History 1 commit
sha256:3ff9c9863a9891bdcde71b4a43228f66d0493e38b7cc1d09fe9eb7de774046b2 feat: add repair-commit wire endpoint (API parity with repa… Opus 4.8 minor 1 day ago