Files
qtalker---/backend/tests/test_ws_passage.py
Indiana a727858a62 fix: the Passage was an unbounded essence faucet
`passage_start` rewinds the rite to `listen` at any time, and PassagePanel
offers exactly that button after a resisted release. Every replayed layer
re-credited its essence, so one summon funded an endless loop — the 20/60s
limiter caps the rate, never the total. Measured at ~110 essence/minute,
indefinitely.

Each layer now pays the first time it opens for a presence and never again,
cleared only by a genuine summon. Re-walking still reveals; it just doesn't
mint. The frame reports what was ACTUALLY credited, so the UI's running
total can't drift from the ledger.

Three further fixes in the same handlers:
- judgment -> passage double-paid a crossing. The passage -> cross_over
  direction was already guarded; the reverse ran free, favor included.
- both handlers read `state.entity["id"]` AFTER their DB round-trip. The
  HTTP telemetry path drives the same SeanceState and can summon
  concurrently, so a crossing could mark the presence that just arrived.
  Pinned before the awaits.

tests/test_ws_passage.py is new, and covers the gap that let all of this
hide: test_passage.py tests the pure module, and nothing exercised these
handlers over a real connection. Efficacy proven by reverting the fix —
the replay test then reports "minted 280 extra essence".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 01:44:47 +00:00

295 lines
10 KiB
Python

"""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"
)