"""WS integration tests for the Passage handlers in app.ws. Why this file exists: `tests/test_passage.py` covers app/passage.py, which is pure — it decides outcomes and returns them. It cannot see the handler that banks the essence those outcomes describe, and that handler is where an unbounded essence faucet lived undetected through a 400-test suite: nothing exercised `passage_start` / `passage_layer` over a real connection. The faucet: `passage_start` rewinds the rite to `listen` at any time, and the UI offers exactly that button after a resisted release (PassagePanel.tsx's "walk it again"). Every replayed layer re-credited its essence, so one summon funded an endless loop. The rate limiter caps how FAST that runs, never how much it totals. These tests are written against the exploit path, not against the implementation: they drive the same frames a browser sends and then read the ledger straight out of the database. """ import uuid import pytest import app.ws from app.entities import fallback_profile from app.inventory import SUMMON_ESSENCE_TRICKLE from app.models.user import User from app.passage import LAYERS, LAYER_ESSENCE from app.rate_limit import RateLimiter class FakeSpiritService: async def mint_profile( self, signature, channel, anomalies, language="en", entropy=None, sky=None ): return fallback_profile(signature) async def fragment(self, source, anomaly, language="en"): return "listen" async def wire_whisper(self, telemetry, language="en"): return "the wire hums" def chat_stream(self, entity, question, history, language="en"): async def gen(): yield "here." return gen() def ambient_ready(self): return False async def _fake_synth(text, voice, profile, instability=0.0): return b"RIFFfake wav bytes" @pytest.fixture(autouse=True) def _fake_spirits(monkeypatch): monkeypatch.setattr(app.ws, "spirit_service", FakeSpiritService()) monkeypatch.setattr(app.ws, "synthesize_spirit_voice", _fake_synth) # Module-level limiters are shared singletons that accumulate real hit # counts across the whole session (precedent: test_ws_ritual_judgment.py). # The passage limiter matters most here — the exploit these tests replay # is deliberately high-volume, and the real 20/60s cap would mask it # behind a rate limit rather than letting us observe the actual total. for name in ( "summon_limiter", "summon_ip_limiter", "question_limiter", "question_ip_limiter", "fragment_limiter", "fragment_ip_limiter", "ritual_limiter", "ritual_ip_limiter", "judgment_limiter", "judgment_ip_limiter", "passage_limiter", "passage_ip_limiter", ): monkeypatch.setattr(app.ws, name, RateLimiter(max_requests=10_000, window_seconds=60)) def _no_twists(monkeypatch): """Pin both Passage draws to "no twist", leaving every other draw real. A PassageDraw of 1.0 never fires, since both twists trigger on `draw < chance`. Only the `passage:` contexts are pinned — blanketing `veil_float` would also steer the summon and manifest draws, and these tests are about the ledger, not about the room's noise. """ real = app.ws.veil_float def selective(entropy, context, *args, **kwargs): if context.startswith("passage:"): return 1.0 return real(entropy, context, *args, **kwargs) monkeypatch.setattr(app.ws, "veil_float", selective) # The exploit never reaches `release`. Crossing latches `passage_crossed`, # which correctly blocks replay — the faucet ran on the four beats BEFORE # release, rewound by the "walk it again" button after a resisted release. BEATS_BEFORE_RELEASE = len(LAYERS) - 1 def _read_until(ws, msg_type, max_frames=80, **match): for _ in range(max_frames): frame = ws.receive_json() if frame.get("type") != msg_type: continue if all(frame.get(key) == value for key, value in match.items()): return frame raise AssertionError(f"never saw frame of type {msg_type!r} matching {match!r}") def _login(sync_client, username): sync_client.post("/auth/register", json={"username": username, "password": "spookyspooky"}) sync_client.post("/auth/login", json={"username": username, "password": "spookyspooky"}) return sync_client.cookies.get("qm_session") def _ws_connect(sync_client, token): return sync_client.websocket_connect( "/ws/session", headers={"cookie": f"qm_session={token}"} ) def _summon(ws): ws.send_json({"type": "summon"}) frame = _read_until(ws, "entity") _read_until(ws, "utterance", kind="greeting") return frame def _settle(ws): """Force a full round trip so any post-frame DB write has landed. The essence credit runs before the frame is queued, but the sender task drains that queue concurrently with the handler's remaining awaits. The connection's message loop is sequential, so a pong is a hard guarantee that the previous handler returned — not a poll-and-hope. """ ws.send_json({"type": "ping"}) _read_until(ws, "pong") def _walk(ws, beats): """Send `beats` passage_layer frames, returning the result frames.""" frames = [] for _ in range(beats): ws.send_json({"type": "passage_layer"}) frames.append(_read_until(ws, "passage_result")) _settle(ws) return frames async def _essence(db_session, user_id): db_session.expire_all() user = await db_session.get(User, user_id) return user.essence def _user_id(sync_client, token): return uuid.UUID( sync_client.get("/auth/me", headers={"cookie": f"qm_session={token}"}).json()["id"] ) # --- the faucet ------------------------------------------------------------ @pytest.mark.asyncio async def test_replaying_the_rite_never_mints_essence_twice( sync_client, db_session, monkeypatch ): """The exploit, executed literally. Walk every layer, hit `passage_start` to rewind, walk them all again — ten times over. The seeker must finish with exactly one rite's worth of essence, because there was only ever one spirit. Before the fix this credited the full ladder on every pass; with the ladder summing to 53, ten passes paid 530 instead of 53. """ # No collapses and no crossing: this test is about the ledger, and a # random rewind or an early `crossed` would make the totals depend on # the draw rather than on the replay guard. _no_twists(monkeypatch) token = _login(sync_client, "faucet-walker") user_id = _user_id(sync_client, token) with _ws_connect(sync_client, token) as ws: _read_until(ws, "session") _summon(ws) baseline = await _essence(db_session, user_id) ws.send_json({"type": "passage_start"}) _settle(ws) _walk(ws, BEATS_BEFORE_RELEASE) after_one_rite = await _essence(db_session, user_id) for _ in range(10): ws.send_json({"type": "passage_start"}) _settle(ws) _walk(ws, BEATS_BEFORE_RELEASE) after_ten_replays = await _essence(db_session, user_id) earned = after_one_rite - baseline assert earned > 0, "the rite paid nothing at all — the test proves nothing" assert earned <= sum(LAYER_ESSENCE.values()) assert after_ten_replays == after_one_rite, ( f"replaying the rite minted {after_ten_replays - after_one_rite} extra essence; " "the passage is an unbounded faucet again" ) @pytest.mark.asyncio async def test_the_reported_essence_matches_what_was_actually_credited( sync_client, db_session, monkeypatch ): """The UI sums the frame's `essence` field into its running total. If a replayed layer reports the layer's face value while the ledger credits nothing, the seeker watches a total climb that their account never receives — the most corrosive kind of bug in a game about trust. """ _no_twists(monkeypatch) token = _login(sync_client, "honest-ledger") user_id = _user_id(sync_client, token) with _ws_connect(sync_client, token) as ws: _read_until(ws, "session") _summon(ws) baseline = await _essence(db_session, user_id) ws.send_json({"type": "passage_start"}) _settle(ws) first = _walk(ws, BEATS_BEFORE_RELEASE) ws.send_json({"type": "passage_start"}) _settle(ws) second = _walk(ws, BEATS_BEFORE_RELEASE) final = await _essence(db_session, user_id) assert sum(f["essence"] for f in first) == final - baseline assert all(f["essence"] == 0 for f in second), ( "a replayed layer advertised essence it did not pay" ) @pytest.mark.asyncio async def test_a_genuinely_new_presence_reopens_the_purse( sync_client, db_session, monkeypatch ): """The guard must not overshoot: each spirit is worth its own rite. A `passage_paid` set that survived a summon would silently make every spirit after the first worthless, which is a worse bug than the faucet. """ _no_twists(monkeypatch) token = _login(sync_client, "second-spirit") user_id = _user_id(sync_client, token) with _ws_connect(sync_client, token) as ws: _read_until(ws, "session") _summon(ws) baseline = await _essence(db_session, user_id) ws.send_json({"type": "passage_start"}) _settle(ws) _walk(ws, BEATS_BEFORE_RELEASE) after_first = await _essence(db_session, user_id) _summon(ws) ws.send_json({"type": "passage_start"}) _settle(ws) _walk(ws, BEATS_BEFORE_RELEASE) after_second = await _essence(db_session, user_id) first_rite = after_first - baseline # The second summon also pays its own trickle, so compare the rite only. second_rite = after_second - after_first - SUMMON_ESSENCE_TRICKLE assert first_rite > 0 assert second_rite == first_rite, ( "a fresh presence did not re-open the purse — every spirit after the " "first is worth nothing" )