The séance tells guests "claim a name to keep your codex". It was a lie. register() unconditionally created a brand-new User row with a fresh UUID, so a wanderer's essence, discovered entities, sightings, ritual/judgment history and Ghost Log — every one of them foreign-keyed to the guest's user_id — were silently orphaned the moment they registered. Now that every visitor starts as a guest, that hit essentially everyone who ever signed up. A wanderer hitting /register now renames that same row in place, keeping all relationships intact. The existing AuthSession stays valid (same user_id), so claiming a name doesn't even log you out. Scoped deliberately to wanderers. My first attempt rejected ANY authenticated caller with a 409, which broke registering a second account while logged in — a legitimate flow (shared computer, alt account) that tests/test_device.py::test_device_feed_only_broadcasts_to_the_owning_user caught immediately: its second register 409'd, its login then failed, and "user B's" device got paired to user A, silently defeating a cross-user-isolation assertion. A named caller's cookie is now ignored and the normal create-a-new-row path runs. Adds get_optional_current_user (None instead of 401) for endpoints that behave differently for anonymous vs. authenticated callers but must stay reachable without auth. This was one of eight findings from an adversarial audit whose verifier agents all died on session limits, so nothing was machine-verified — I confirmed this one by reading the code and then proving it end-to-end. The other seven remain unchecked. 331 backend tests pass. Verified live: guest 23846555 -> livehunter1, same id, same session still valid. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
148 lines
5.4 KiB
Python
148 lines
5.4 KiB
Python
import pytest
|
|
|
|
import app.routes.auth as auth_module
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _reset_guest_limiter():
|
|
# The limiter is module-level state; a previous test's hits would bleed
|
|
# into the next one's per-IP budget.
|
|
auth_module.guest_limiter._hits.clear()
|
|
yield
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_guest_creates_wanderer_and_cookie_works_on_me(client):
|
|
response = await client.post("/auth/guest")
|
|
assert response.status_code == 201
|
|
body = response.json()
|
|
assert body["username"].startswith("wanderer-")
|
|
assert "password" not in body
|
|
assert "qm_session" in response.cookies
|
|
|
|
me_resp = await client.get("/auth/me")
|
|
assert me_resp.status_code == 200
|
|
assert me_resp.json()["username"] == body["username"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_guest_cannot_login_with_any_password(client):
|
|
response = await client.post("/auth/guest")
|
|
username = response.json()["username"]
|
|
login_resp = await client.post(
|
|
"/auth/login", json={"username": username, "password": "anythingatall"}
|
|
)
|
|
assert login_resp.status_code == 401
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_guest_rate_limit_fires_per_ip(client):
|
|
for _ in range(5):
|
|
response = await client.post("/auth/guest")
|
|
assert response.status_code == 201
|
|
response = await client.post("/auth/guest")
|
|
assert response.status_code == 429
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_guest_username_collision_retries(client, monkeypatch):
|
|
taken_resp = await client.post("/auth/guest")
|
|
taken = taken_resp.json()["username"].removeprefix("wanderer-")
|
|
|
|
# First attempt collides with the existing wanderer; the retry must land
|
|
# on the fresh suffix instead of erroring out.
|
|
suffixes = iter([taken, "f4ee"])
|
|
monkeypatch.setattr(
|
|
auth_module.secrets, "token_hex", lambda n: next(suffixes)
|
|
)
|
|
response = await client.post("/auth/guest")
|
|
assert response.status_code == 201
|
|
assert response.json()["username"] == "wanderer-f4ee"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_registering_as_a_wanderer_claims_the_same_account_in_place(client, db_session):
|
|
"""The whole point of 'claim a name to keep your codex': registering
|
|
while a guest must upgrade the SAME row, not abandon it for a new one.
|
|
Proven by crediting essence to the guest first, then checking it
|
|
survives registration under the guest's still-valid cookie."""
|
|
from sqlalchemy import select
|
|
|
|
from app.inventory import credit_essence
|
|
from app.models.user import User
|
|
|
|
guest_resp = await client.post("/auth/guest")
|
|
guest_id = guest_resp.json()["id"]
|
|
guest_username = guest_resp.json()["username"]
|
|
|
|
user = await db_session.get(User, guest_id)
|
|
credit_essence(user, 37)
|
|
await db_session.commit()
|
|
|
|
register_resp = await client.post(
|
|
"/auth/register", json={"username": "claimedname", "password": "spookyspooky1"}
|
|
)
|
|
assert register_resp.status_code == 201
|
|
body = register_resp.json()
|
|
# Same id, same essence — this is an upgrade, not a fresh account.
|
|
assert body["id"] == guest_id
|
|
assert body["username"] == "claimedname"
|
|
assert body["essence"] == 37
|
|
|
|
# The old wanderer username no longer resolves to a row at all — it was
|
|
# renamed in place, not duplicated.
|
|
old = await db_session.scalar(select(User).where(User.username == guest_username))
|
|
assert old is None
|
|
|
|
# The guest's original cookie is still a valid session for this same,
|
|
# now-named, account — claiming a name doesn't log you out.
|
|
me_resp = await client.get("/auth/me")
|
|
assert me_resp.status_code == 200
|
|
assert me_resp.json()["id"] == guest_id
|
|
assert me_resp.json()["username"] == "claimedname"
|
|
|
|
# And the new credentials actually work, for a future login elsewhere.
|
|
login_resp = await client.post(
|
|
"/auth/login", json={"username": "claimedname", "password": "spookyspooky1"}
|
|
)
|
|
assert login_resp.status_code == 200
|
|
assert login_resp.json()["id"] == guest_id
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_registering_while_already_named_creates_a_separate_account(client, db_session):
|
|
"""Registering a second account while logged in is legitimate (shared
|
|
computer, alt account), so a named caller's cookie is ignored and a
|
|
genuinely new row is created — the in-place claim is for wanderers only.
|
|
It must NOT rename the logged-in account out from under them."""
|
|
first = await client.post(
|
|
"/auth/register", json={"username": "alreadynamed", "password": "spookyspooky1"}
|
|
)
|
|
first_id = first.json()["id"]
|
|
await client.post(
|
|
"/auth/login", json={"username": "alreadynamed", "password": "spookyspooky1"}
|
|
)
|
|
|
|
second = await client.post(
|
|
"/auth/register", json={"username": "somethingelse", "password": "spookyspooky2"}
|
|
)
|
|
assert second.status_code == 201
|
|
assert second.json()["id"] != first_id
|
|
|
|
# The original account still exists under its own name, untouched.
|
|
from app.models.user import User
|
|
|
|
still_there = await db_session.get(User, first_id)
|
|
assert still_there is not None
|
|
assert still_there.username == "alreadynamed"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_anonymous_registration_is_unaffected(client):
|
|
# No cookie at all — the original, pre-guest behaviour must be intact.
|
|
resp = await client.post(
|
|
"/auth/register", json={"username": "freshaccount", "password": "spookyspooky1"}
|
|
)
|
|
assert resp.status_code == 201
|
|
assert resp.json()["username"] == "freshaccount"
|