Files
plm-lottery/tests/unit/test_auth.py
T
davideandClaude Opus 5 57721355f0 Make usernames one case-insensitive namespace (B-57)
The login throttle keyed on body.username.lower() while the lookup matched
User.username exactly, so "Bob" and "bob" were two accounts sharing one
rate-limit bucket — each able to lock the other out — and registration happily
accepted near-duplicate names, which on a custodial system is an impersonation
vector.

Uniqueness is now the database's job: a unique index on lower(username), with
register and login both matching through func.lower(). The name is still stored
exactly as typed, since that's what /admin and the audit log display, and the
username pattern is ASCII-only so lower() is the whole of the normalization.

The migration refuses to run if two existing accounts differ only by case. It
can't merge or rename one automatically: both are custodial accounts that may
hold funds, so that would be the migration silently deciding who owns what. It
names the collisions and leaves them to the operator — the container runs
`alembic upgrade head` at startup, so it surfaces as a refusal to start rather
than a half-applied schema. Verified both directions against a scratch DB, plus
`alembic check` (clean) and the collision guard actually firing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 22:27:15 +02:00

171 lines
6.5 KiB
Python

import pytest
from cryptography.fernet import Fernet
from httpx import ASGITransport, AsyncClient
from app.config import settings
@pytest.fixture
async def client(monkeypatch, tmp_path):
monkeypatch.setattr(settings, "database_url", f"sqlite+aiosqlite:///{tmp_path}/test.db")
monkeypatch.setattr(settings, "jwt_secret", "test-jwt-secret")
monkeypatch.setattr(settings, "xprv_encryption_key", Fernet.generate_key().decode())
monkeypatch.setattr(settings, "master_key_path", str(tmp_path / "master.xprv.enc"))
import app.wallet.hd as hd
hd._account_key = None
hd.generate_master_key()
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.db import base as db_base
import app.db.models # noqa: F401
db_base.engine = create_async_engine(settings.database_url)
db_base.AsyncSessionLocal = async_sessionmaker(db_base.engine, expire_on_commit=False)
from app.db import session as db_session
db_session.AsyncSessionLocal = db_base.AsyncSessionLocal
async with db_base.engine.begin() as conn:
await conn.run_sync(db_base.Base.metadata.create_all)
from fastapi import FastAPI
from app.auth.routes import router as auth_router
from app.electrum.listener import ElectrumListener
app = FastAPI()
app.include_router(auth_router)
app.state.electrum_listener = ElectrumListener(lambda endpoint: None, db_base.AsyncSessionLocal)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac
await db_base.engine.dispose()
async def _register(client, username="alice", password="original-password"):
resp = await client.post("/auth/register", json={"username": username, "password": password})
assert resp.status_code == 201
return resp.json()["access_token"]
async def test_login_locks_out_after_repeated_failures(client):
await _register(client)
for _ in range(5):
resp = await client.post("/auth/login", json={"username": "alice", "password": "wrong"})
assert resp.status_code == 401
resp = await client.post("/auth/login", json={"username": "alice", "password": "wrong"})
assert resp.status_code == 429
assert resp.json()["detail"]["code"] == "rate_limited"
# Even the *correct* password is refused while locked out — the throttle
# protects against a lucky guess landing inside the backoff window too.
resp = await client.post("/auth/login", json={"username": "alice", "password": "original-password"})
assert resp.status_code == 429
async def test_unknown_username_and_wrong_password_share_a_bucket_and_response(client):
await _register(client, username="bob")
for _ in range(5):
resp = await client.post("/auth/login", json={"username": "nobody", "password": "wrong"})
assert resp.status_code == 401
assert resp.json()["detail"]["code"] == "invalid_credentials"
resp = await client.post("/auth/login", json={"username": "nobody", "password": "wrong"})
assert resp.status_code == 429
async def test_login_failures_against_one_account_do_not_lock_out_another(client):
await _register(client, username="alice")
await _register(client, username="carol", password="carols-password")
for _ in range(6):
await client.post("/auth/login", json={"username": "alice", "password": "wrong"})
# Different username, but same IP (the test client always looks the same) —
# only the per-username bucket should be exhausted, not the whole IP, since
# the per-username threshold (5) is hit well before the shared IP bucket's.
resp = await client.post("/auth/login", json={"username": "carol", "password": "carols-password"})
assert resp.status_code == 200
async def test_successful_login_resets_the_username_bucket(client):
await _register(client)
for _ in range(4):
resp = await client.post("/auth/login", json={"username": "alice", "password": "wrong"})
assert resp.status_code == 401
resp = await client.post("/auth/login", json={"username": "alice", "password": "original-password"})
assert resp.status_code == 200
resp = await client.post("/auth/login", json={"username": "alice", "password": "original-password"})
assert resp.status_code == 200
async def test_registration_is_rate_limited_per_ip(client):
for i in range(5):
resp = await client.post(
"/auth/register", json={"username": f"user{i}", "password": "a-strong-password"}
)
assert resp.status_code == 201
resp = await client.post(
"/auth/register", json={"username": "user5", "password": "a-strong-password"}
)
assert resp.status_code == 429
assert resp.json()["detail"]["code"] == "rate_limited"
# --- B-57: usernames are one namespace, case included ---------------------------
async def test_registration_refuses_a_username_differing_only_by_case(client):
""""Bob" and "bob" used to be two accounts. On a custodial system that's an
impersonation vector — and the two also shared a single rate-limit bucket, since
the throttle key has always been lowercased, so each could lock the other out."""
await _register(client, username="Bob")
resp = await client.post("/auth/register", json={"username": "bob", "password": "another-password"})
assert resp.status_code == 409
assert resp.json()["detail"]["code"] == "username_taken"
async def test_login_accepts_the_username_in_any_case(client):
"""The flip side of the same rule: one account, reachable however it's typed."""
await _register(client, username="Alice", password="original-password")
resp = await client.post("/auth/login", json={"username": "ALICE", "password": "original-password"})
assert resp.status_code == 200
assert resp.json()["access_token"]
async def test_the_database_itself_rejects_a_case_variant(client):
"""Not just the pre-check in the handler: two requests racing between the SELECT
and the INSERT must still leave only one account, which is what the unique index
on lower(username) guarantees."""
from sqlalchemy.exc import IntegrityError
from app.db.base import AsyncSessionLocal
from app.db.models import User
await _register(client, username="Carol")
async with AsyncSessionLocal() as session:
session.add(
User(username="CAROL", password_hash="x", derivation_index=999, address="plm1-unused")
)
with pytest.raises(IntegrityError):
await session.commit()