test_musehub_auth_challenge_db.py
python
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