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_quota_limited_per_ip(client): # B-58 """Five accounts per IP per hour. The sixth is told to wait, not punished with a backoff that doubles from there.""" 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() async def test_failed_registrations_do_not_consume_the_quota(client): # B-58 """The limit is on accounts that exist, not on requests: record_failure used to fire on every attempt, so five signups — successful ones included — locked the sixth real user out for up to 600s from a shared or NAT address. Attempts that create nothing must leave the quota untouched.""" await _register(client, username="taken") for _ in range(10): resp = await client.post( "/auth/register", json={"username": "taken", "password": "a-strong-password"} ) assert resp.status_code == 409 # username_taken, no account created # Four slots left out of five, all still usable. for i in range(4): resp = await client.post( "/auth/register", json={"username": f"genuine{i}", "password": "a-strong-password"} ) assert resp.status_code == 201 async def test_the_quota_reports_how_long_to_wait(client): # B-58 for i in range(5): await _register(client, username=f"quotauser{i}", password="a-strong-password") resp = await client.post( "/auth/register", json={"username": "one-too-many", "password": "a-strong-password"} ) assert resp.status_code == 429 retry_after = resp.json()["detail"]["params"]["retry_after_seconds"] assert 0 < retry_after <= 3601 # bounded by the window, not by a growing penalty