diff --git a/backend/app/deps.py b/backend/app/deps.py index 9405dad..44067a2 100644 --- a/backend/app/deps.py +++ b/backend/app/deps.py @@ -28,3 +28,21 @@ async def get_current_user( if user is None: raise HTTPException(status.HTTP_401_UNAUTHORIZED, "user not found") return user + + +async def get_optional_current_user( + qm_session: str | None = Cookie(default=None, alias=SESSION_COOKIE_NAME), + db: AsyncSession = Depends(get_db), +) -> User | None: + """Same lookup as get_current_user, but None instead of a 401 when + there is no valid session — for endpoints (like /auth/register) that + behave differently for an anonymous caller vs. an already-authenticated + one, but must not require auth to be reachable at all.""" + if qm_session is None: + return None + token_hash = hash_token(qm_session) + result = await db.execute(select(AuthSession).where(AuthSession.token_hash == token_hash)) + session = result.scalar_one_or_none() + if session is None or session.expires_at < datetime.now(timezone.utc): + return None + return await db.get(User, session.user_id) diff --git a/backend/app/routes/auth.py b/backend/app/routes/auth.py index 85e232a..3446b2f 100644 --- a/backend/app/routes/auth.py +++ b/backend/app/routes/auth.py @@ -6,7 +6,7 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.db import get_db -from app.deps import SESSION_COOKIE_NAME, get_current_user +from app.deps import SESSION_COOKIE_NAME, get_current_user, get_optional_current_user from app.models.auth_session import AuthSession, SESSION_TTL, generate_session_token, hash_token from app.models.unlock import UnlockRecord from app.models.user import User @@ -27,13 +27,46 @@ guest_limiter = RateLimiter(max_requests=5, window_seconds=3600) # namespace is effectively exhausted, not that we got unlucky. _GUEST_NAME_ATTEMPTS = 8 +# Shared with the guest-provisioning endpoint below, and with the frontend's +# own username-prefix check (SeancePage's claim-a-name nudge) — a wanderer +# is any user row whose username starts with this. +WANDERER_PREFIX = "wanderer-" + @router.post("/register", response_model=UserOut, status_code=status.HTTP_201_CREATED) -async def register(payload: RegisterRequest, db: AsyncSession = Depends(get_db)): +async def register( + payload: RegisterRequest, + db: AsyncSession = Depends(get_db), + current_user: User | None = Depends(get_optional_current_user), +): existing = await db.scalar(select(User).where(User.username == payload.username)) if existing is not None: raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="username taken") + # A *wanderer* hitting /register is claiming a name for the account they + # already have, not opening a new one — this is what makes the séance's + # "claim a name to keep your codex" nudge true rather than a lie. + # Previously register() always created a brand-new User row, so a + # guest's essence, discovered entities, ritual/judgment history and + # Ghost Log (all foreign-keyed to the guest's user_id) were silently + # abandoned the moment they registered — the exact opposite of what the + # UI promises. Renaming the SAME row in place keeps every one of those + # relationships intact, and the existing AuthSession stays valid (same + # user_id), so claiming a name doesn't log you out. + # + # Deliberately scoped to wanderers only: a caller who already has a real + # name is registering a SECOND account (a legitimate thing to do while + # logged in — shared computer, alt account), so their cookie is ignored + # and the normal create-a-new-row path runs. An earlier version of this + # rejected that case outright and broke exactly that flow. + if current_user is not None and current_user.username.startswith(WANDERER_PREFIX): + current_user.username = payload.username + current_user.password_hash = hash_password(payload.password) + current_user.email = payload.email + await db.commit() + await db.refresh(current_user) + return current_user + user = User( username=payload.username, password_hash=hash_password(payload.password), @@ -61,7 +94,7 @@ async def guest( ) for _ in range(_GUEST_NAME_ATTEMPTS): - username = f"wanderer-{secrets.token_hex(2)}" + username = f"{WANDERER_PREFIX}{secrets.token_hex(2)}" existing = await db.scalar(select(User).where(User.username == username)) if existing is None: break diff --git a/backend/tests/test_auth_guest.py b/backend/tests/test_auth_guest.py index c186697..3f8cc22 100644 --- a/backend/tests/test_auth_guest.py +++ b/backend/tests/test_auth_guest.py @@ -58,3 +58,90 @@ async def test_guest_username_collision_retries(client, monkeypatch): 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"