feat: add external id for users (#3219)
This commit is contained in:
parent
ff24847980
commit
c7b7832a88
11 changed files with 458 additions and 25 deletions
|
|
@ -68,6 +68,7 @@ async def get_accounts(
|
||||||
accounts.username,
|
accounts.username,
|
||||||
accounts.email,
|
accounts.email,
|
||||||
accounts.pubkey,
|
accounts.pubkey,
|
||||||
|
accounts.external_id,
|
||||||
SUM(COALESCE((
|
SUM(COALESCE((
|
||||||
SELECT balance FROM balances WHERE wallet_id = wallets.id
|
SELECT balance FROM balances WHERE wallet_id = wallets.id
|
||||||
), 0)) as balance_msat,
|
), 0)) as balance_msat,
|
||||||
|
|
@ -128,8 +129,8 @@ async def get_account_by_username(
|
||||||
if len(username) == 0:
|
if len(username) == 0:
|
||||||
return None
|
return None
|
||||||
return await (conn or db).fetchone(
|
return await (conn or db).fetchone(
|
||||||
"SELECT * FROM accounts WHERE username = :username",
|
"SELECT * FROM accounts WHERE LOWER(username) = :username",
|
||||||
{"username": username},
|
{"username": username.lower()},
|
||||||
Account,
|
Account,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -138,8 +139,8 @@ async def get_account_by_pubkey(
|
||||||
pubkey: str, conn: Optional[Connection] = None
|
pubkey: str, conn: Optional[Connection] = None
|
||||||
) -> Optional[Account]:
|
) -> Optional[Account]:
|
||||||
return await (conn or db).fetchone(
|
return await (conn or db).fetchone(
|
||||||
"SELECT * FROM accounts WHERE pubkey = :pubkey",
|
"SELECT * FROM accounts WHERE LOWER(pubkey) = :pubkey",
|
||||||
{"pubkey": pubkey},
|
{"pubkey": pubkey.lower()},
|
||||||
Account,
|
Account,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -150,8 +151,8 @@ async def get_account_by_email(
|
||||||
if len(email) == 0:
|
if len(email) == 0:
|
||||||
return None
|
return None
|
||||||
return await (conn or db).fetchone(
|
return await (conn or db).fetchone(
|
||||||
"SELECT * FROM accounts WHERE email = :email",
|
"SELECT * FROM accounts WHERE LOWER(email) = :email",
|
||||||
{"email": email},
|
{"email": email.lower()},
|
||||||
Account,
|
Account,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -160,8 +161,11 @@ async def get_account_by_username_or_email(
|
||||||
username_or_email: str, conn: Optional[Connection] = None
|
username_or_email: str, conn: Optional[Connection] = None
|
||||||
) -> Optional[Account]:
|
) -> Optional[Account]:
|
||||||
return await (conn or db).fetchone(
|
return await (conn or db).fetchone(
|
||||||
"SELECT * FROM accounts WHERE email = :value or username = :value",
|
"""
|
||||||
{"value": username_or_email},
|
SELECT * FROM accounts
|
||||||
|
WHERE LOWER(email) = :value or LOWER(username) = :value
|
||||||
|
""",
|
||||||
|
{"value": username_or_email.lower()},
|
||||||
Account,
|
Account,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -183,6 +187,7 @@ async def get_user_from_account(
|
||||||
email=account.email,
|
email=account.email,
|
||||||
username=account.username,
|
username=account.username,
|
||||||
pubkey=account.pubkey,
|
pubkey=account.pubkey,
|
||||||
|
external_id=account.external_id,
|
||||||
extra=account.extra,
|
extra=account.extra,
|
||||||
created_at=account.created_at,
|
created_at=account.created_at,
|
||||||
updated_at=account.updated_at,
|
updated_at=account.updated_at,
|
||||||
|
|
|
||||||
|
|
@ -711,3 +711,11 @@ async def m031_add_color_and_icon_to_wallets(db: Connection):
|
||||||
Adds icon and color columns to wallets.
|
Adds icon and color columns to wallets.
|
||||||
"""
|
"""
|
||||||
await db.execute("ALTER TABLE wallets ADD COLUMN extra TEXT")
|
await db.execute("ALTER TABLE wallets ADD COLUMN extra TEXT")
|
||||||
|
|
||||||
|
|
||||||
|
async def m032_add_external_id_to_accounts(db: Connection):
|
||||||
|
"""
|
||||||
|
Adds external_id column to accounts.
|
||||||
|
Used for external account linking.
|
||||||
|
"""
|
||||||
|
await db.execute("ALTER TABLE accounts ADD COLUMN external_id TEXT")
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,12 @@ from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from lnbits.core.models.misc import SimpleItem
|
from lnbits.core.models.misc import SimpleItem
|
||||||
from lnbits.db import FilterModel
|
from lnbits.db import FilterModel
|
||||||
from lnbits.helpers import is_valid_email_address, is_valid_pubkey, is_valid_username
|
from lnbits.helpers import (
|
||||||
|
is_valid_email_address,
|
||||||
|
is_valid_external_id,
|
||||||
|
is_valid_pubkey,
|
||||||
|
is_valid_username,
|
||||||
|
)
|
||||||
from lnbits.settings import settings
|
from lnbits.settings import settings
|
||||||
|
|
||||||
from .wallets import Wallet
|
from .wallets import Wallet
|
||||||
|
|
@ -93,6 +98,7 @@ class UserAcls(BaseModel):
|
||||||
|
|
||||||
class Account(BaseModel):
|
class Account(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
|
external_id: str | None = None # for external account linking
|
||||||
username: str | None = None
|
username: str | None = None
|
||||||
password_hash: str | None = None
|
password_hash: str | None = None
|
||||||
pubkey: str | None = None
|
pubkey: str | None = None
|
||||||
|
|
@ -130,6 +136,11 @@ class Account(BaseModel):
|
||||||
raise ValueError("Invalid email.")
|
raise ValueError("Invalid email.")
|
||||||
if self.pubkey and not is_valid_pubkey(self.pubkey):
|
if self.pubkey and not is_valid_pubkey(self.pubkey):
|
||||||
raise ValueError("Invalid pubkey.")
|
raise ValueError("Invalid pubkey.")
|
||||||
|
if self.external_id and not is_valid_external_id(self.external_id):
|
||||||
|
raise ValueError(
|
||||||
|
"Invalid external id. Max length is 256 characters. "
|
||||||
|
"Space and newlines are not allowed."
|
||||||
|
)
|
||||||
user_uuid4 = UUID(hex=self.id, version=4)
|
user_uuid4 = UUID(hex=self.id, version=4)
|
||||||
if user_uuid4.hex != self.id:
|
if user_uuid4.hex != self.id:
|
||||||
raise ValueError("User ID is not valid UUID4 hex string.")
|
raise ValueError("User ID is not valid UUID4 hex string.")
|
||||||
|
|
@ -143,7 +154,14 @@ class AccountOverview(Account):
|
||||||
|
|
||||||
|
|
||||||
class AccountFilters(FilterModel):
|
class AccountFilters(FilterModel):
|
||||||
__search_fields__ = ["user", "email", "username", "pubkey", "wallet_id"]
|
__search_fields__ = [
|
||||||
|
"user",
|
||||||
|
"email",
|
||||||
|
"username",
|
||||||
|
"pubkey",
|
||||||
|
"external_id",
|
||||||
|
"wallet_id",
|
||||||
|
]
|
||||||
__sort_fields__ = [
|
__sort_fields__ = [
|
||||||
"balance_msat",
|
"balance_msat",
|
||||||
"email",
|
"email",
|
||||||
|
|
@ -157,6 +175,7 @@ class AccountFilters(FilterModel):
|
||||||
user: str | None = None
|
user: str | None = None
|
||||||
username: str | None = None
|
username: str | None = None
|
||||||
pubkey: str | None = None
|
pubkey: str | None = None
|
||||||
|
external_id: str | None = None
|
||||||
wallet_id: str | None = None
|
wallet_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -167,6 +186,7 @@ class User(BaseModel):
|
||||||
email: str | None = None
|
email: str | None = None
|
||||||
username: str | None = None
|
username: str | None = None
|
||||||
pubkey: str | None = None
|
pubkey: str | None = None
|
||||||
|
external_id: str | None = None # for external account linking
|
||||||
extensions: list[str] = []
|
extensions: list[str] = []
|
||||||
wallets: list[Wallet] = []
|
wallets: list[Wallet] = []
|
||||||
admin: bool = False
|
admin: bool = False
|
||||||
|
|
@ -207,13 +227,13 @@ class CreateUser(BaseModel):
|
||||||
password: str | None = Query(default=None, min_length=8, max_length=50)
|
password: str | None = Query(default=None, min_length=8, max_length=50)
|
||||||
password_repeat: str | None = Query(default=None, min_length=8, max_length=50)
|
password_repeat: str | None = Query(default=None, min_length=8, max_length=50)
|
||||||
pubkey: str = Query(default=None, max_length=64)
|
pubkey: str = Query(default=None, max_length=64)
|
||||||
|
external_id: str = Query(default=None, max_length=256)
|
||||||
extensions: list[str] | None = None
|
extensions: list[str] | None = None
|
||||||
extra: UserExtra | None = None
|
extra: UserExtra | None = None
|
||||||
|
|
||||||
|
|
||||||
class UpdateUser(BaseModel):
|
class UpdateUser(BaseModel):
|
||||||
user_id: str
|
user_id: str
|
||||||
email: str | None = Query(default=None)
|
|
||||||
username: str | None = Query(default=..., min_length=2, max_length=20)
|
username: str | None = Query(default=..., min_length=2, max_length=20)
|
||||||
extra: UserExtra | None = None
|
extra: UserExtra | None = None
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -272,10 +272,21 @@
|
||||||
class="q-mb-md"
|
class="q-mb-md"
|
||||||
>
|
>
|
||||||
</q-input>
|
</q-input>
|
||||||
|
<q-input
|
||||||
|
v-model="user.external_id"
|
||||||
|
:label="$t('external_id')"
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
readonly
|
||||||
|
class="q-mb-md"
|
||||||
|
>
|
||||||
|
</q-input>
|
||||||
|
|
||||||
<q-input
|
<q-input
|
||||||
v-model="user.extra.picture"
|
v-model="user.extra.picture"
|
||||||
:label="$t('picture')"
|
:label="$t('picture')"
|
||||||
filled
|
filled
|
||||||
|
dense
|
||||||
class="q-mb-md"
|
class="q-mb-md"
|
||||||
>
|
>
|
||||||
</q-input>
|
</q-input>
|
||||||
|
|
|
||||||
|
|
@ -147,6 +147,14 @@
|
||||||
class="q-mb-md"
|
class="q-mb-md"
|
||||||
>
|
>
|
||||||
</q-input>
|
</q-input>
|
||||||
|
<q-input
|
||||||
|
v-model="activeUser.data.external_id"
|
||||||
|
:label="$t('external_id')"
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
class="q-mb-md"
|
||||||
|
>
|
||||||
|
</q-input>
|
||||||
<q-input
|
<q-input
|
||||||
v-model="activeUser.data.extra.picture"
|
v-model="activeUser.data.extra.picture"
|
||||||
:label="$t('picture')"
|
:label="$t('picture')"
|
||||||
|
|
|
||||||
|
|
@ -411,23 +411,13 @@ async def update(
|
||||||
raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid user ID.")
|
raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid user ID.")
|
||||||
if data.username and not is_valid_username(data.username):
|
if data.username and not is_valid_username(data.username):
|
||||||
raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid username.")
|
raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid username.")
|
||||||
if data.email != user.email:
|
|
||||||
raise HTTPException(
|
|
||||||
HTTPStatus.BAD_REQUEST,
|
|
||||||
"Email mismatch.",
|
|
||||||
)
|
|
||||||
if (
|
if (
|
||||||
data.username
|
data.username
|
||||||
and user.username != data.username
|
and user.username != data.username
|
||||||
and await get_account_by_username(data.username)
|
and await get_account_by_username(data.username)
|
||||||
):
|
):
|
||||||
raise HTTPException(HTTPStatus.BAD_REQUEST, "Username already exists.")
|
raise HTTPException(HTTPStatus.BAD_REQUEST, "Username already exists.")
|
||||||
if (
|
|
||||||
data.email
|
|
||||||
and data.email != user.email
|
|
||||||
and await get_account_by_email(data.email)
|
|
||||||
):
|
|
||||||
raise HTTPException(HTTPStatus.BAD_REQUEST, "Email already exists.")
|
|
||||||
|
|
||||||
account = await get_account(user.id)
|
account = await get_account(user.id)
|
||||||
if not account:
|
if not account:
|
||||||
|
|
@ -435,8 +425,6 @@ async def update(
|
||||||
|
|
||||||
if data.username:
|
if data.username:
|
||||||
account.username = data.username
|
account.username = data.username
|
||||||
if data.email:
|
|
||||||
account.email = data.email
|
|
||||||
if data.extra:
|
if data.extra:
|
||||||
account.extra = data.extra
|
account.extra = data.extra
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -100,6 +100,7 @@ async def api_create_user(data: CreateUser) -> CreateUser:
|
||||||
username=data.username,
|
username=data.username,
|
||||||
email=data.email,
|
email=data.email,
|
||||||
pubkey=data.pubkey,
|
pubkey=data.pubkey,
|
||||||
|
external_id=data.external_id,
|
||||||
extra=data.extra,
|
extra=data.extra,
|
||||||
)
|
)
|
||||||
account.validate_fields()
|
account.validate_fields()
|
||||||
|
|
@ -132,6 +133,7 @@ async def api_update_user(
|
||||||
username=data.username,
|
username=data.username,
|
||||||
email=data.email,
|
email=data.email,
|
||||||
pubkey=data.pubkey,
|
pubkey=data.pubkey,
|
||||||
|
external_id=data.external_id,
|
||||||
extra=data.extra or UserExtra(),
|
extra=data.extra or UserExtra(),
|
||||||
)
|
)
|
||||||
await update_user_account(account)
|
await update_user_account(account)
|
||||||
|
|
|
||||||
|
|
@ -198,6 +198,14 @@ def is_valid_username(username: str) -> bool:
|
||||||
return re.fullmatch(username_regex, username) is not None
|
return re.fullmatch(username_regex, username) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def is_valid_external_id(external_id: str) -> bool:
|
||||||
|
if len(external_id) > 256:
|
||||||
|
return False
|
||||||
|
if " " in external_id or "\n" in external_id:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def is_valid_pubkey(pubkey: str) -> bool:
|
def is_valid_pubkey(pubkey: str) -> bool:
|
||||||
if len(pubkey) != 64:
|
if len(pubkey) != 64:
|
||||||
return False
|
return False
|
||||||
|
|
|
||||||
2
lnbits/static/bundle.min.js
vendored
2
lnbits/static/bundle.min.js
vendored
File diff suppressed because one or more lines are too long
|
|
@ -350,6 +350,7 @@ window.localisation.en = {
|
||||||
update_account: 'Update Account',
|
update_account: 'Update Account',
|
||||||
invalid_username: 'Invalid Username',
|
invalid_username: 'Invalid Username',
|
||||||
auth_provider: 'Auth Provider',
|
auth_provider: 'Auth Provider',
|
||||||
|
external_id: 'External ID',
|
||||||
my_account: 'My Account',
|
my_account: 'My Account',
|
||||||
existing_account_question: 'Already have an account?',
|
existing_account_question: 'Already have an account?',
|
||||||
background_image: 'Background Image',
|
background_image: 'Background Image',
|
||||||
|
|
|
||||||
382
tests/api/test_users.py
Normal file
382
tests/api/test_users.py
Normal file
|
|
@ -0,0 +1,382 @@
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import shortuuid
|
||||||
|
from httpx import AsyncClient
|
||||||
|
|
||||||
|
from lnbits.core.models.users import User
|
||||||
|
from lnbits.settings import Settings
|
||||||
|
from lnbits.utils.nostr import generate_keypair
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_create_user_success(http_client: AsyncClient, superuser_token):
|
||||||
|
tiny_id = shortuuid.uuid()[:8]
|
||||||
|
data = {
|
||||||
|
"username": f"user_{tiny_id}",
|
||||||
|
"password": "secret1234",
|
||||||
|
"password_repeat": "secret1234",
|
||||||
|
"email": f"user_{tiny_id}@lnbits.com",
|
||||||
|
}
|
||||||
|
response = await http_client.post(
|
||||||
|
"/users/api/v1/user",
|
||||||
|
json=data,
|
||||||
|
headers={"Authorization": f"Bearer {superuser_token}"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
resp = response.json()
|
||||||
|
assert resp["username"] == data["username"]
|
||||||
|
assert resp["email"] == data["email"]
|
||||||
|
assert resp["id"] is not None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_create_user_passwords_do_not_match(
|
||||||
|
http_client: AsyncClient, superuser_token
|
||||||
|
):
|
||||||
|
tiny_id = shortuuid.uuid()[:8]
|
||||||
|
data = {
|
||||||
|
"username": f"user_{tiny_id}",
|
||||||
|
"password": "secret1234",
|
||||||
|
"password_repeat": "secret0000",
|
||||||
|
"email": f"user_{tiny_id}@lnbits.com",
|
||||||
|
}
|
||||||
|
response = await http_client.post(
|
||||||
|
"/users/api/v1/user",
|
||||||
|
json=data,
|
||||||
|
headers={"Authorization": f"Bearer {superuser_token}"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert response.json()["detail"] == "Passwords do not match."
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_create_user_missing_username_with_password(
|
||||||
|
http_client: AsyncClient, superuser_token
|
||||||
|
):
|
||||||
|
data = {
|
||||||
|
"password": "secret1234",
|
||||||
|
"password_repeat": "secret1234",
|
||||||
|
"email": "nouser@lnbits.com",
|
||||||
|
}
|
||||||
|
response = await http_client.post(
|
||||||
|
"/users/api/v1/user",
|
||||||
|
json=data,
|
||||||
|
headers={"Authorization": f"Bearer {superuser_token}"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert response.json()["detail"] == "Username required when password provided."
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_create_user_no_password_random_generated(
|
||||||
|
http_client: AsyncClient, superuser_token
|
||||||
|
):
|
||||||
|
tiny_id = shortuuid.uuid()[:8]
|
||||||
|
data = {
|
||||||
|
"username": f"user_{tiny_id}",
|
||||||
|
"email": f"user_{tiny_id}@lnbits.com",
|
||||||
|
}
|
||||||
|
response = await http_client.post(
|
||||||
|
"/users/api/v1/user",
|
||||||
|
json=data,
|
||||||
|
headers={"Authorization": f"Bearer {superuser_token}"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
resp = response.json()
|
||||||
|
assert resp["username"] == data["username"]
|
||||||
|
assert resp["email"] == data["email"]
|
||||||
|
assert resp["id"] is not None
|
||||||
|
assert resp["password"] is not None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_create_user_with_extensions_and_extra(
|
||||||
|
http_client: AsyncClient, superuser_token
|
||||||
|
):
|
||||||
|
tiny_id = shortuuid.uuid()[:8]
|
||||||
|
data = {
|
||||||
|
"username": f"user_{tiny_id}",
|
||||||
|
"password": "secret1234",
|
||||||
|
"password_repeat": "secret1234",
|
||||||
|
"email": f"user_{tiny_id}@lnbits.com",
|
||||||
|
"extensions": ["testext1", "testext2"],
|
||||||
|
"extra": {"provider": "custom", "foo": "bar"},
|
||||||
|
}
|
||||||
|
response = await http_client.post(
|
||||||
|
"/users/api/v1/user",
|
||||||
|
json=data,
|
||||||
|
headers={"Authorization": f"Bearer {superuser_token}"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
resp = response.json()
|
||||||
|
assert resp["username"] == data["username"]
|
||||||
|
assert resp["email"] == data["email"]
|
||||||
|
assert resp["id"] is not None
|
||||||
|
assert resp["extra"]["provider"] == "custom"
|
||||||
|
assert "foo" not in resp["extra"], "random fields should not be in extra"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_create_user_minimum_fields(http_client: AsyncClient, superuser_token):
|
||||||
|
data: dict[str, str] = {}
|
||||||
|
response = await http_client.post(
|
||||||
|
"/users/api/v1/user",
|
||||||
|
json=data,
|
||||||
|
headers={"Authorization": f"Bearer {superuser_token}"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
resp = response.json()
|
||||||
|
assert resp["id"] is not None
|
||||||
|
assert resp["extra"]["provider"] == "lnbits"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_create_user_duplicate_username(
|
||||||
|
http_client: AsyncClient, superuser_token
|
||||||
|
):
|
||||||
|
tiny_id = shortuuid.uuid()[:8]
|
||||||
|
username = f"user_{tiny_id}"
|
||||||
|
data = {
|
||||||
|
"username": username,
|
||||||
|
"password": "secret1234",
|
||||||
|
"password_repeat": "secret1234",
|
||||||
|
"email": f"user_{tiny_id}@lnbits.com",
|
||||||
|
}
|
||||||
|
# First creation should succeed
|
||||||
|
response1 = await http_client.post(
|
||||||
|
"/users/api/v1/user",
|
||||||
|
json=data,
|
||||||
|
headers={"Authorization": f"Bearer {superuser_token}"},
|
||||||
|
)
|
||||||
|
assert response1.status_code == 200
|
||||||
|
# Second creation with same username should fail
|
||||||
|
data2 = data.copy()
|
||||||
|
data2["email"] = f"other_{tiny_id}@lnbits.com"
|
||||||
|
response2 = await http_client.post(
|
||||||
|
"/users/api/v1/user",
|
||||||
|
json=data2,
|
||||||
|
headers={"Authorization": f"Bearer {superuser_token}"},
|
||||||
|
)
|
||||||
|
assert response2.status_code == 400 or response2.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_create_user_duplicate_email(http_client: AsyncClient, superuser_token):
|
||||||
|
tiny_id = shortuuid.uuid()[:8]
|
||||||
|
email = f"user_{tiny_id}@lnbits.com"
|
||||||
|
data = {
|
||||||
|
"username": f"user_{tiny_id}",
|
||||||
|
"password": "secret1234",
|
||||||
|
"password_repeat": "secret1234",
|
||||||
|
"email": email,
|
||||||
|
}
|
||||||
|
# First creation should succeed
|
||||||
|
response1 = await http_client.post(
|
||||||
|
"/users/api/v1/user",
|
||||||
|
json=data,
|
||||||
|
headers={"Authorization": f"Bearer {superuser_token}"},
|
||||||
|
)
|
||||||
|
assert response1.status_code == 200
|
||||||
|
# Second creation with same email should fail
|
||||||
|
data2 = data.copy()
|
||||||
|
data2["username"] = f"other_{tiny_id}"
|
||||||
|
response2 = await http_client.post(
|
||||||
|
"/users/api/v1/user",
|
||||||
|
json=data2,
|
||||||
|
headers={"Authorization": f"Bearer {superuser_token}"},
|
||||||
|
)
|
||||||
|
assert response2.status_code == 400 or response2.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_update_user_success(http_client: AsyncClient, superuser_token):
|
||||||
|
# Create a user first
|
||||||
|
tiny_id = shortuuid.uuid()[:8]
|
||||||
|
data = {
|
||||||
|
"username": f"update_{tiny_id}",
|
||||||
|
"password": "secret1234",
|
||||||
|
"password_repeat": "secret1234",
|
||||||
|
"email": f"update_{tiny_id}@lnbits.com",
|
||||||
|
}
|
||||||
|
create_resp = await http_client.post(
|
||||||
|
"/users/api/v1/user",
|
||||||
|
json=data,
|
||||||
|
headers={"Authorization": f"Bearer {superuser_token}"},
|
||||||
|
)
|
||||||
|
assert create_resp.status_code == 200
|
||||||
|
user_id = create_resp.json()["id"]
|
||||||
|
|
||||||
|
# Update the user
|
||||||
|
_, pubkey = generate_keypair()
|
||||||
|
update_data = {
|
||||||
|
"id": user_id,
|
||||||
|
"username": f"updated_{tiny_id}",
|
||||||
|
"email": f"updated_{tiny_id}@lnbits.com",
|
||||||
|
"pubkey": pubkey,
|
||||||
|
"external_id": "external_1234",
|
||||||
|
"extra": {"provider": "lnbits"},
|
||||||
|
"extensions": [],
|
||||||
|
}
|
||||||
|
resp = await http_client.put(
|
||||||
|
f"/users/api/v1/user/{user_id}",
|
||||||
|
json=update_data,
|
||||||
|
headers={"Authorization": f"Bearer {superuser_token}"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["username"] == update_data["username"]
|
||||||
|
assert resp.json()["email"] == update_data["email"]
|
||||||
|
assert resp.json()["pubkey"] == update_data["pubkey"]
|
||||||
|
assert resp.json()["external_id"] == update_data["external_id"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_update_bad_external_id(
|
||||||
|
http_client: AsyncClient, user_alan: User, superuser_token
|
||||||
|
):
|
||||||
|
|
||||||
|
update_data = {"id": user_alan.id, "external_id": "external 1234"}
|
||||||
|
resp = await http_client.put(
|
||||||
|
f"/users/api/v1/user/{user_alan.id}",
|
||||||
|
json=update_data,
|
||||||
|
headers={"Authorization": f"Bearer {superuser_token}"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert (
|
||||||
|
resp.json()["detail"] == "Invalid external id. "
|
||||||
|
"Max length is 256 characters. Space and newlines are not allowed."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_update_user_id_mismatch(http_client: AsyncClient, superuser_token):
|
||||||
|
# Create a user first
|
||||||
|
tiny_id = shortuuid.uuid()[:8]
|
||||||
|
data = {
|
||||||
|
"username": f"mismatch_{tiny_id}",
|
||||||
|
"password": "secret1234",
|
||||||
|
"password_repeat": "secret1234",
|
||||||
|
"email": f"mismatch_{tiny_id}@lnbits.com",
|
||||||
|
}
|
||||||
|
create_resp = await http_client.post(
|
||||||
|
"/users/api/v1/user",
|
||||||
|
json=data,
|
||||||
|
headers={"Authorization": f"Bearer {superuser_token}"},
|
||||||
|
)
|
||||||
|
assert create_resp.status_code == 200
|
||||||
|
user_id = create_resp.json()["id"]
|
||||||
|
|
||||||
|
# Try to update with mismatched id
|
||||||
|
update_data: dict[str, Any] = {
|
||||||
|
"id": "wrongid",
|
||||||
|
"username": f"updated_{tiny_id}",
|
||||||
|
"email": f"updated_{tiny_id}@lnbits.com",
|
||||||
|
"extra": {"provider": "lnbits"},
|
||||||
|
"extensions": [],
|
||||||
|
}
|
||||||
|
resp = await http_client.put(
|
||||||
|
f"/users/api/v1/user/{user_id}",
|
||||||
|
json=update_data,
|
||||||
|
headers={"Authorization": f"Bearer {superuser_token}"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert resp.json()["detail"] == "User Id missmatch."
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_update_user_password_fields(http_client: AsyncClient, superuser_token):
|
||||||
|
# Create a user first
|
||||||
|
tiny_id = shortuuid.uuid()[:8]
|
||||||
|
data = {
|
||||||
|
"username": f"pwfield_{tiny_id}",
|
||||||
|
"password": "secret1234",
|
||||||
|
"password_repeat": "secret1234",
|
||||||
|
"email": f"pwfield_{tiny_id}@lnbits.com",
|
||||||
|
}
|
||||||
|
create_resp = await http_client.post(
|
||||||
|
"/users/api/v1/user",
|
||||||
|
json=data,
|
||||||
|
headers={"Authorization": f"Bearer {superuser_token}"},
|
||||||
|
)
|
||||||
|
assert create_resp.status_code == 200
|
||||||
|
user_id = create_resp.json()["id"]
|
||||||
|
|
||||||
|
# Try to update with password fields set
|
||||||
|
update_data = {
|
||||||
|
"id": user_id,
|
||||||
|
"username": f"updated_{tiny_id}",
|
||||||
|
"email": f"updated_{tiny_id}@lnbits.com",
|
||||||
|
"extra": {"provider": "lnbits"},
|
||||||
|
"extensions": [],
|
||||||
|
"password": "newpass1234",
|
||||||
|
"password_repeat": "newpass1234",
|
||||||
|
}
|
||||||
|
resp = await http_client.put(
|
||||||
|
f"/users/api/v1/user/{user_id}",
|
||||||
|
json=update_data,
|
||||||
|
headers={"Authorization": f"Bearer {superuser_token}"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert resp.json()["detail"] == "Use 'reset password' functionality."
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_update_user_invalid_username(http_client: AsyncClient, superuser_token):
|
||||||
|
# Create a user first
|
||||||
|
tiny_id = shortuuid.uuid()[:8]
|
||||||
|
data = {
|
||||||
|
"username": f"valid_{tiny_id}",
|
||||||
|
"password": "secret1234",
|
||||||
|
"password_repeat": "secret1234",
|
||||||
|
"email": f"valid_{tiny_id}@lnbits.com",
|
||||||
|
}
|
||||||
|
create_resp = await http_client.post(
|
||||||
|
"/users/api/v1/user",
|
||||||
|
json=data,
|
||||||
|
headers={"Authorization": f"Bearer {superuser_token}"},
|
||||||
|
)
|
||||||
|
assert create_resp.status_code == 200
|
||||||
|
user_id = create_resp.json()["id"]
|
||||||
|
|
||||||
|
# Try to update with invalid username
|
||||||
|
update_data = {
|
||||||
|
"id": user_id,
|
||||||
|
"username": "!@#invalid", # invalid username
|
||||||
|
"email": f"valid_{tiny_id}@lnbits.com",
|
||||||
|
"extra": {"provider": "lnbits"},
|
||||||
|
"extensions": [],
|
||||||
|
}
|
||||||
|
resp = await http_client.put(
|
||||||
|
f"/users/api/v1/user/{user_id}",
|
||||||
|
json=update_data,
|
||||||
|
headers={"Authorization": f"Bearer {superuser_token}"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert resp.json()["detail"] == "Invalid username."
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_update_superuser_only_allowed_by_superuser(
|
||||||
|
http_client: AsyncClient, user_alan: User, settings: Settings
|
||||||
|
):
|
||||||
|
response = await http_client.post("/api/v1/auth/usr", json={"usr": user_alan.id})
|
||||||
|
|
||||||
|
assert response.status_code == 200, "Alan logs in OK."
|
||||||
|
alan_access_token = response.json().get("access_token")
|
||||||
|
assert alan_access_token is not None, "Expected access token after login."
|
||||||
|
settings.lnbits_admin_users = [user_alan.id]
|
||||||
|
update_data: dict[str, Any] = {
|
||||||
|
"id": settings.super_user,
|
||||||
|
"username": "superadmin",
|
||||||
|
"email": "superadmin@lnbits.com",
|
||||||
|
"extra": {"provider": "lnbits"},
|
||||||
|
"extensions": [],
|
||||||
|
}
|
||||||
|
resp = await http_client.put(
|
||||||
|
f"/users/api/v1/user/{settings.super_user}",
|
||||||
|
json=update_data,
|
||||||
|
headers={"Authorization": f"Bearer {alan_access_token}"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.json()["detail"] == "Action only allowed for super user."
|
||||||
Loading…
Add table
Add a link
Reference in a new issue