From 8725e1a2b7927f1cd45f396a453669d0ac47a64a Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Wed, 2 Oct 2024 18:44:11 +0300 Subject: [PATCH] fix: api tests --- lnbits/core/crud.py | 1 + lnbits/core/services.py | 5 +- lnbits/core/views/api.py | 13 ++--- lnbits/core/views/auth_api.py | 89 ++++++++++++++--------------------- lnbits/core/views/generic.py | 5 +- lnbits/exceptions.py | 8 ++++ tests/api/test_auth.py | 54 +++++++++++---------- tests/conftest.py | 30 +++++++----- 8 files changed, 98 insertions(+), 107 deletions(-) diff --git a/lnbits/core/crud.py b/lnbits/core/crud.py index 1faf52ee1..01617c92a 100644 --- a/lnbits/core/crud.py +++ b/lnbits/core/crud.py @@ -176,6 +176,7 @@ async def get_user( id=account.id, email=account.email, username=account.username, + pubkey=account.pubkey, extra=account.extra, created_at=account.created_at, updated_at=account.updated_at, diff --git a/lnbits/core/services.py b/lnbits/core/services.py index 81dd9f487..7afb5ec8a 100644 --- a/lnbits/core/services.py +++ b/lnbits/core/services.py @@ -54,6 +54,7 @@ from .crud import ( create_wallet, get_account, get_account_by_email, + get_account_by_pubkey, get_account_by_username, get_payments, get_standalone_payment, @@ -849,8 +850,10 @@ async def create_user_account( if account.email and await get_account_by_email(account.email): raise ValueError("Email already exists.") + if account.pubkey and await get_account_by_pubkey(account.pubkey): + raise ValueError("Pubkey already exists.") + if account.id: - print("### account", account) user_uuid4 = UUID(hex=account.id, version=4) assert user_uuid4.hex == account.id, "User ID is not valid UUID4 hex string" else: diff --git a/lnbits/core/views/api.py b/lnbits/core/views/api.py index 18eba2804..92b0c22f6 100644 --- a/lnbits/core/views/api.py +++ b/lnbits/core/views/api.py @@ -14,7 +14,6 @@ from fastapi import ( from fastapi.exceptions import HTTPException from fastapi.responses import StreamingResponse -from lnbits.core.crud import create_account, create_wallet from lnbits.core.models import ( BaseWallet, ConversionData, @@ -38,7 +37,7 @@ from lnbits.utils.exchange_rates import ( satoshis_amount_as_fiat, ) -from ..services import perform_lnurlauth +from ..services import create_user_account, perform_lnurlauth api_router = APIRouter(tags=["Core"]) @@ -63,14 +62,8 @@ async def api_wallets(user: User = Depends(check_user_exists)) -> list[BaseWalle @api_router.post("/api/v1/account", response_model=Wallet) async def api_create_account(data: CreateWallet) -> Wallet: - if not settings.new_accounts_allowed: - raise HTTPException( - status_code=HTTPStatus.FORBIDDEN, - detail="Account creation is disabled.", - ) - account = await create_account() - wallet = await create_wallet(user_id=account.id, wallet_name=data.name) - return wallet + user = await create_user_account(wallet_name=data.name) + return user.wallets[0] @api_router.get("/api/v1/lnurlscan/{code}") diff --git a/lnbits/core/views/auth_api.py b/lnbits/core/views/auth_api.py index 234f8559c..090e4e314 100644 --- a/lnbits/core/views/auth_api.py +++ b/lnbits/core/views/auth_api.py @@ -11,6 +11,7 @@ from fastapi.responses import JSONResponse, RedirectResponse from fastapi_sso.sso.base import OpenID, SSOBase from loguru import logger +from lnbits.core.services import create_user_account from lnbits.decorators import access_token_payload, check_user_exists from lnbits.helpers import ( create_access_token, @@ -18,13 +19,11 @@ from lnbits.helpers import ( encrypt_internal_message, is_valid_email_address, is_valid_username, - urlsafe_short_hash, ) from lnbits.settings import AuthMethods, settings from lnbits.utils.nostr import normalize_public_key, verify_event from ..crud import ( - create_account, get_account, get_account_by_email, get_account_by_pubkey, @@ -84,7 +83,7 @@ async def nostr_login(request: Request) -> JSONResponse: pubkey=event["pubkey"], extra=UserExtra(provider="nostr"), ) - await create_account(account) + await create_user_account(account) return _auth_success_response(account.username or "", account.id, account.email) @@ -182,12 +181,12 @@ async def register(data: CreateUser) -> JSONResponse: raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail="Invalid email.") account = Account( - id=urlsafe_short_hash(), + id=uuid4().hex, email=data.email, username=data.username, ) account.hash_password(data.password) - await create_account(account) + await create_user_account(account) return _auth_success_response(account.username, account.id, account.email) @@ -225,36 +224,22 @@ async def update_password( payload: AccessTokenPayload = Depends(access_token_payload), ) -> Optional[User]: if data.user_id != user.id: - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, detail="Invalid user ID." - ) + raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid user ID.") + _validate_auth_timeout(payload.auth_time) if ( data.username and user.username != data.username and await get_account_by_username(data.username) ): - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, detail="Username already exists." - ) + raise HTTPException(HTTPStatus.BAD_REQUEST, "Username already exists.") account = await get_account(user.id) - if not account: - raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, detail="Account not found." - ) + assert account, "Account not found." # old accounts do not have a password if account.password_hash: - if not data.password_old: - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, detail="Missing old password." - ) - if not account.verify_password(data.password_old): - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, detail="Invalid credentials." - ) - - _validate_auth_timeout(payload.auth_time) + assert data.password_old, "Missing old password." + assert account.verify_password(data.password_old), "Invalid old password." account.username = data.username account.hash_password(data.password) @@ -275,9 +260,12 @@ async def reset_password(data: ResetUserPassword) -> JSONResponse: assert data.password == data.password_repeat, "Passwords do not match." assert data.reset_key[:10].startswith("reset_key_"), "This is not a reset key." - reset_data_json = decrypt_internal_message( - base64.b64decode(data.reset_key[10:]).decode() - ) + try: + reset_key = base64.b64decode(data.reset_key[10:]).decode() + reset_data_json = decrypt_internal_message(reset_key) + except Exception as exc: + raise ValueError("Invalid reset key.") from exc + assert reset_data_json, "Cannot process reset key." action, user_id, request_time = json.loads(reset_data_json) @@ -384,12 +372,10 @@ async def _handle_sso_login(userinfo: OpenID, verified_user_id: Optional[str] = account.extra.email_verified = True await update_account(account) else: - if not settings.new_accounts_allowed: - raise HTTPException(HTTPStatus.BAD_REQUEST, "Account creation is disabled.") account = Account( id=uuid4().hex, email=email, extra=UserExtra(email_verified=True) ) - await create_account(account) + await create_user_account(account) return _auth_redirect_response(redirect_path, email) @@ -476,40 +462,35 @@ def _nostr_nip98_event(request: Request) -> dict: event = json.loads(event_json) except Exception as exc: logger.warning(exc) - if not event: - raise HTTPException( - HTTPStatus.BAD_REQUEST, "Nostr login event cannot be parsed." - ) + assert event, "Nostr login event cannot be parsed." + if not verify_event(event): raise HTTPException(HTTPStatus.BAD_REQUEST, "Nostr login event is not valid.") - if event["kind"] != 27_235: - raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid event kind.") + assert event["kind"] == 27_235, "Invalid event kind." + auth_threshold = settings.auth_credetials_update_threshold - if abs(time() - event["created_at"]) > auth_threshold: - raise HTTPException( - HTTPStatus.BAD_REQUEST, - f"{auth_threshold} seconds have passed since the event was signed.", - ) + assert ( + abs(time() - event["created_at"]) < auth_threshold + ), f"More than {auth_threshold} seconds have passed since the event was signed." + method: Optional[str] = next((v for k, v in event["tags"] if k == "method"), None) - if not method: - raise HTTPException(HTTPStatus.BAD_REQUEST, "Tag 'method' is missing.") - if method.upper() != "POST": - raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid value for tag 'method'.") + assert method, "Tag 'method' is missing." + assert method.upper() == "POST", "Invalid value for tag 'method'." url = next((v for k, v in event["tags"] if k == "u"), None) - if not url: - raise HTTPException(HTTPStatus.BAD_REQUEST, "Tag 'u' for URL is missing.") + + assert url, "Tag 'u' for URL is missing." accepted_urls = [f"{u}/nostr" for u in settings.nostr_absolute_request_urls] - if url not in accepted_urls: - raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid value for tag 'u'.") + assert url in accepted_urls, f"Invalid value for tag 'u': '{url}'." + return event -def _validate_auth_timeout(auth_time: Optional[int] = None): - if int(time()) - int(auth_time or 0) > settings.auth_credetials_update_threshold: +def _validate_auth_timeout(auth_time: Optional[int] = 0): + if abs(time() - (auth_time or 0)) > settings.auth_credetials_update_threshold: raise HTTPException( HTTPStatus.BAD_REQUEST, "You can only update your credentials in the first" - f" {settings.auth_credetials_update_threshold} seconds after login." - " Please login again!", + f" {settings.auth_credetials_update_threshold} seconds." + " Please login again or ask a new reset key!", ) diff --git a/lnbits/core/views/generic.py b/lnbits/core/views/generic.py index 707ae360d..85694cac3 100644 --- a/lnbits/core/views/generic.py +++ b/lnbits/core/views/generic.py @@ -14,7 +14,7 @@ from pydantic.types import UUID4 from lnbits.core.extensions.models import Extension, ExtensionMeta, InstallableExtension from lnbits.core.helpers import to_valid_user_id from lnbits.core.models import User -from lnbits.core.services import create_invoice +from lnbits.core.services import create_invoice, create_user_account from lnbits.decorators import check_admin, check_user_exists from lnbits.helpers import template_renderer from lnbits.settings import settings @@ -22,7 +22,6 @@ from lnbits.wallets import get_funding_source from ...utils.exchange_rates import allowed_currencies, currencies from ..crud import ( - create_account, create_wallet, get_dbversions, get_installed_extensions, @@ -424,7 +423,7 @@ async def lnurlwallet(request: Request): status_code=HTTPStatus.BAD_REQUEST, detail="Invalid lnurl. Expected maxWithdrawable", ) - account = await create_account() + account = await create_user_account() wallet = await create_wallet(user_id=account.id) _, payment_request = await create_invoice( wallet_id=wallet.id, diff --git a/lnbits/exceptions.py b/lnbits/exceptions.py index bc73898d3..1f5e63c21 100644 --- a/lnbits/exceptions.py +++ b/lnbits/exceptions.py @@ -76,6 +76,14 @@ def register_exception_handlers(app: FastAPI): content={"detail": str(exc)}, ) + @app.exception_handler(ValueError) + async def value_error_handler(request: Request, exc: ValueError): + logger.warning(f"ValueError: {exc!s}") + return render_html_error(request, exc) or JSONResponse( + status_code=HTTPStatus.BAD_REQUEST, + content={"detail": str(exc)}, + ) + @app.exception_handler(RequestValidationError) async def validation_exception_handler( request: Request, exc: RequestValidationError diff --git a/tests/api/test_auth.py b/tests/api/test_auth.py index 89e4596cc..4959534b2 100644 --- a/tests/api/test_auth.py +++ b/tests/api/test_auth.py @@ -255,7 +255,8 @@ async def test_register_email_twice(http_client: AsyncClient): "email": f"u21.{tiny_id}@lnbits.com", }, ) - assert response.status_code == 403, "Not allowed." + + assert response.status_code == 400, "Not allowed." assert response.json().get("detail") == "Email already exists." @@ -285,7 +286,7 @@ async def test_register_username_twice(http_client: AsyncClient): "email": f"u21.{tiny_id_2}@lnbits.com", }, ) - assert response.status_code == 403, "Not allowed." + assert response.status_code == 400, "Not allowed." assert response.json().get("detail") == "Username already exists." @@ -414,8 +415,8 @@ async def test_alan_change_password_old_nok(user_alan: User, http_client: AsyncC }, ) - assert response.status_code == 403, "Old password bad." - assert response.json().get("detail") == "Invalid credentials." + assert response.status_code == 400, "Old password bad." + assert response.json().get("detail") == "Invalid old password." @pytest.mark.asyncio @@ -469,7 +470,7 @@ async def test_alan_change_password_auth_threshold_expired( }, ) - assert response.status_code == 403, "Treshold expired." + assert response.status_code == 400 assert ( response.json().get("detail") == "You can only update your credentials" " in the first 1 seconds." @@ -507,6 +508,7 @@ async def test_register_nostr_ok(http_client: AsyncClient): response = await http_client.get( "/api/v1/auth", headers={"Authorization": f"Bearer {access_token}"} ) + user = User(**response.json()) assert user.username is None, "No username." assert user.email is None, "No email." @@ -547,13 +549,13 @@ async def test_register_nostr_bad_header(http_client: AsyncClient): ) assert response.status_code == 401, "Non nostr header." - assert response.json().get("detail") == "Authorization header is not nostr." + assert response.json().get("detail") == "Invalid Authorization scheme." response = await http_client.post( "/api/v1/auth/nostr", headers={"Authorization": "nostr xyz"}, ) - assert response.status_code == 401, "Nostr not base64." + assert response.status_code == 400, "Nostr not base64." assert response.json().get("detail") == "Nostr login event cannot be parsed." @@ -565,7 +567,7 @@ async def test_register_nostr_bad_event(http_client: AsyncClient): "/api/v1/auth/nostr", headers={"Authorization": f"nostr {base64_event}"}, ) - assert response.status_code == 401, "Nostr event expired." + assert response.status_code == 400, "Nostr event expired." assert ( response.json().get("detail") == f"More than {settings.auth_credetials_update_threshold}" @@ -581,7 +583,7 @@ async def test_register_nostr_bad_event(http_client: AsyncClient): "/api/v1/auth/nostr", headers={"Authorization": f"nostr {base64_event}"}, ) - assert response.status_code == 401, "Nostr event signature invalid." + assert response.status_code == 400, "Nostr event signature invalid." assert response.json().get("detail") == "Nostr login event is not valid." @@ -598,7 +600,7 @@ async def test_register_nostr_bad_event_kind(http_client: AsyncClient): "/api/v1/auth/nostr", headers={"Authorization": f"nostr {base64_event_bad_kind}"}, ) - assert response.status_code == 401, "Nostr event kind invalid." + assert response.status_code == 400, "Nostr event kind invalid." assert response.json().get("detail") == "Invalid event kind." @@ -617,7 +619,7 @@ async def test_register_nostr_bad_event_tag_u(http_client: AsyncClient): "/api/v1/auth/nostr", headers={"Authorization": f"nostr {base64_event_tag_kind}"}, ) - assert response.status_code == 401, "Nostr event tag missing." + assert response.status_code == 400, "Nostr event tag missing." assert response.json().get("detail") == "Tag 'method' is missing." event_bad_kind["tags"] = [["u", "http://localhost:5000/nostr"], ["method", "XYZ"]] @@ -630,8 +632,8 @@ async def test_register_nostr_bad_event_tag_u(http_client: AsyncClient): "/api/v1/auth/nostr", headers={"Authorization": f"nostr {base64_event_tag_kind}"}, ) - assert response.status_code == 401, "Nostr event tag invalid." - assert response.json().get("detail") == "Incorrect value for tag 'method'." + assert response.status_code == 400, "Nostr event tag invalid." + assert response.json().get("detail") == "Invalid value for tag 'method'." @pytest.mark.asyncio @@ -649,7 +651,7 @@ async def test_register_nostr_bad_event_tag_menthod(http_client: AsyncClient): "/api/v1/auth/nostr", headers={"Authorization": f"nostr {base64_event}"}, ) - assert response.status_code == 401, "Nostr event tag missing." + assert response.status_code == 400, "Nostr event tag missing." assert response.json().get("detail") == "Tag 'u' for URL is missing." event_bad_kind["tags"] = [["u", "http://demo.lnbits.com/nostr"], ["method", "POST"]] @@ -662,15 +664,15 @@ async def test_register_nostr_bad_event_tag_menthod(http_client: AsyncClient): "/api/v1/auth/nostr", headers={"Authorization": f"nostr {base64_event}"}, ) - assert response.status_code == 401, "Nostr event tag invalid." + assert response.status_code == 400, "Nostr event tag invalid." assert ( - response.json().get("detail") == "Incorrect value for tag 'u':" + response.json().get("detail") == "Invalid value for tag 'u':" " 'http://demo.lnbits.com/nostr'." ) ################################ CHANGE PUBLIC KEY ################################ -async def test_change_pubkey_npub_ok(http_client: AsyncClient, user_alan: User): +async def test_change_pubkey_npub_ok(http_client: AsyncClient): tiny_id = shortuuid.uuid()[:8] response = await http_client.post( "/api/v1/auth/register", @@ -790,7 +792,7 @@ async def test_change_pubkey_ok(http_client: AsyncClient, user_alan: User): }, ) - assert response.status_code == 403, "Pubkey already used." + assert response.status_code == 400, "Pubkey already used." assert response.json().get("detail") == "Public key already in use." @@ -852,11 +854,11 @@ async def test_alan_change_pubkey_auth_threshold_expired( }, ) - assert response.status_code == 403, "Treshold expired." + assert response.status_code == 400, "Treshold expired." assert ( response.json().get("detail") == "You can only update your credentials" - " in the first 1 seconds after login." - " Please login again!" + " in the first 1 seconds." + " Please login again or ask a new reset key!" ) @@ -929,7 +931,7 @@ async def test_request_reset_key_user_not_found(http_client: AsyncClient): }, ) - assert response.status_code == 403, "User does not exist." + assert response.status_code == 404, "User does not exist." assert response.json().get("detail") == "User not found." @@ -975,7 +977,7 @@ async def test_reset_username_passwords_do_not_matcj( }, ) - assert response.status_code == 403, "Passwords do not match." + assert response.status_code == 400, "Passwords do not match." assert response.json().get("detail") == "Passwords do not match." @@ -990,8 +992,8 @@ async def test_reset_username_password_bad_key(http_client: AsyncClient): "password_repeat": "secret0000", }, ) - assert response.status_code == 500, "Bad reset key." - assert response.json().get("detail") == "Cannot reset user password." + assert response.status_code == 400, "Bad reset key." + assert response.json().get("detail") == "Invalid reset key." @pytest.mark.asyncio @@ -1013,7 +1015,7 @@ async def test_reset_password_auth_threshold_expired( }, ) - assert response.status_code == 403, "Treshold expired." + assert response.status_code == 400, "Treshold expired." assert ( response.json().get("detail") == "You can only update your credentials" " in the first 1 seconds." diff --git a/tests/conftest.py b/tests/conftest.py index 9d47aea46..2a2f4e3c0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -16,15 +16,15 @@ from httpx import ASGITransport, AsyncClient from lnbits.app import create_app from lnbits.core.crud import ( - create_account, create_wallet, + delete_account, get_account, get_account_by_username, get_user, update_payment_status, ) from lnbits.core.models import Account, CreateInvoice, PaymentState -from lnbits.core.services import update_wallet_balance +from lnbits.core.services import create_user_account, update_wallet_balance from lnbits.core.views.payment_api import api_payments_create_invoice from lnbits.db import DB_TYPE, SQLITE, Database from lnbits.settings import AuthMethods, settings @@ -46,6 +46,7 @@ def run_before_and_after_tests(): ##### BEFORE TEST RUN ##### settings.lnbits_allow_new_accounts = True + settings.lnbits_allowed_users = [] settings.auth_allowed_methods = AuthMethods.all() settings.auth_credetials_update_threshold = 120 settings.lnbits_reserve_fee_percent = 1 @@ -103,20 +104,23 @@ async def db(): @pytest_asyncio.fixture(scope="session") async def user_alan(): account = await get_account_by_username("alan") - if not account: - account = Account( - id=uuid4().hex, - email="alan@lnbits.com", - username="alan", - ) - account.hash_password("secret1234") - account = await create_account(account) - yield account + if account: + await delete_account(account.id) + + account = Account( + id=uuid4().hex, + email="alan@lnbits.com", + username="alan", + ) + account.hash_password("secret1234") + user = await create_user_account(account) + + yield user @pytest_asyncio.fixture(scope="session") async def from_user(): - user = await create_account() + user = await create_user_account() yield user @@ -141,7 +145,7 @@ async def from_wallet_ws(from_wallet, test_client): @pytest_asyncio.fixture(scope="session") async def to_user(): - user = await create_account() + user = await create_user_account() yield user