feat(T01): auth + user management + RBAC + base frontend + i18n

- Backend: FastAPI, JWT auth (HS256), bcrypt, RBAC middleware
- User CRUD (admin-only), soft-delete, pagination
- Pydantic BaseSettings config, async SQLAlchemy 2.0
- 50 backend tests, 88% coverage
- Frontend: Next.js 14 App Router, Tailwind, design tokens
- Login page, auth context, API client with auto-refresh
- i18n: next-intl, DE/EN (31 keys each)
- 6 base UI components (Button, Input, Card, Table, Modal, Toast)
- 12 frontend tests, npm build success
This commit is contained in:
2026-07-14 11:51:32 +02:00
parent 5459a43e2b
commit d89304845a
56 changed files with 7581 additions and 9 deletions
+173
View File
@@ -0,0 +1,173 @@
"""Pytest fixtures for async DB testing with PostgreSQL."""
from typing import AsyncGenerator
import pytest
import pytest_asyncio
from httpx import ASGITransport, AsyncClient
from sqlalchemy import delete
from sqlalchemy.ext.asyncio import (
AsyncSession,
async_sessionmaker,
create_async_engine,
)
from app.database import Base, get_db
from app.main import app
from app.models.user import User, UserRole
from app.services.auth_service import hash_password
TEST_DATABASE_URL = (
"postgresql+asyncpg://erp_test_user:testpass@localhost:5432/erp_test"
)
@pytest.fixture
def event_loop():
"""Create a fresh event loop per test for asyncpg compatibility."""
import asyncio
loop = asyncio.new_event_loop()
yield loop
loop.close()
@pytest_asyncio.fixture
async def test_engine():
"""Create a fresh async engine per test."""
engine = create_async_engine(TEST_DATABASE_URL, echo=False, pool_pre_ping=True)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield engine
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
await engine.dispose()
@pytest_asyncio.fixture
async def test_session_factory(test_engine):
"""Session factory bound to the test engine."""
return async_sessionmaker(
test_engine,
class_=AsyncSession,
expire_on_commit=False,
)
@pytest_asyncio.fixture
async def db_session(test_session_factory) -> AsyncGenerator[AsyncSession, None]:
"""Provide an async DB session for tests."""
async with test_session_factory() as session:
try:
yield session
finally:
await session.close()
@pytest_asyncio.fixture
async def admin_user(db_session: AsyncSession) -> User:
"""Create a test admin user."""
user = User(
email="admin@test.com",
password_hash=hash_password("Admin12345!"),
full_name="Test Admin",
role=UserRole.admin,
language="de",
is_active=True,
)
db_session.add(user)
await db_session.commit()
await db_session.refresh(user)
return user
@pytest_asyncio.fixture
async def verkaeufer_user(db_session: AsyncSession) -> User:
"""Create a test verkaeufer (non-admin) user."""
user = User(
email="verkaeufer@test.com",
password_hash=hash_password("Verk12345!"),
full_name="Test Verkaeufer",
role=UserRole.verkaeufer,
language="en",
is_active=True,
)
db_session.add(user)
await db_session.commit()
await db_session.refresh(user)
return user
@pytest_asyncio.fixture
async def inactive_user(db_session: AsyncSession) -> User:
"""Create a deactivated test user."""
user = User(
email="inactive@test.com",
password_hash=hash_password("Inactive123!"),
full_name="Test Inactive",
role=UserRole.admin,
language="de",
is_active=False,
)
db_session.add(user)
await db_session.commit()
await db_session.refresh(user)
return user
def _get_test_token(user: User) -> str:
"""Generate an access token for a test user."""
from app.utils.jwt import create_access_token
role_val = user.role.value if isinstance(user.role, UserRole) else str(user.role)
return create_access_token(
user_id=str(user.id),
role=role_val,
email=user.email,
lang=user.language,
)
@pytest_asyncio.fixture
async def admin_token(admin_user: User) -> str:
"""Access token for admin user."""
return _get_test_token(admin_user)
@pytest_asyncio.fixture
async def verkaeufer_token(verkaeufer_user: User) -> str:
"""Access token for verkaeufer user."""
return _get_test_token(verkaeufer_user)
@pytest_asyncio.fixture
async def client(test_session_factory) -> AsyncGenerator[AsyncClient, None]:
"""Async HTTP test client with DB session override."""
async def _override_get_db():
async with test_session_factory() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
finally:
await session.close()
app.dependency_overrides[get_db] = _override_get_db
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac
app.dependency_overrides.clear()
@pytest_asyncio.fixture
async def admin_client(client: AsyncClient, admin_token: str) -> AsyncClient:
"""HTTP client authenticated as admin."""
client.headers.update({"Authorization": f"Bearer {admin_token}"})
return client
@pytest_asyncio.fixture
async def verkaeufer_client(client: AsyncClient, verkaeufer_token: str) -> AsyncClient:
"""HTTP client authenticated as verkaeufer (non-admin)."""
client.headers.update({"Authorization": f"Bearer {verkaeufer_token}"})
return client
+155
View File
@@ -0,0 +1,155 @@
"""Tests for auth endpoints: login, refresh, me."""
import pytest
from httpx import AsyncClient
from app.models.user import User
from app.utils.jwt import create_access_token, create_refresh_token
@pytest.mark.asyncio
async def test_login_valid_credentials(client: AsyncClient, admin_user: User):
"""POST /api/v1/auth/login with valid credentials returns 200 + JWT."""
response = await client.post("/api/v1/auth/login", json={
"email": "admin@test.com",
"password": "Admin12345!",
})
assert response.status_code == 200
data = response.json()
assert "access_token" in data
assert "refresh_token" in data
assert data["token_type"] == "bearer"
assert data["expires_in"] > 0
@pytest.mark.asyncio
async def test_login_invalid_password(client: AsyncClient, admin_user: User):
"""POST /api/v1/auth/login with wrong password returns 401."""
response = await client.post("/api/v1/auth/login", json={
"email": "admin@test.com",
"password": "WrongPassword!",
})
assert response.status_code == 401
data = response.json()
assert "error" in data["detail"]
@pytest.mark.asyncio
async def test_login_nonexistent_user(client: AsyncClient):
"""POST /api/v1/auth/login with unknown email returns 401."""
response = await client.post("/api/v1/auth/login", json={
"email": "nobody@test.com",
"password": "SomePassword!",
})
assert response.status_code == 401
@pytest.mark.asyncio
async def test_login_inactive_user(client: AsyncClient, inactive_user: User):
"""POST /api/v1/auth/login with deactivated account returns 401."""
response = await client.post("/api/v1/auth/login", json={
"email": "inactive@test.com",
"password": "Inactive123!",
})
assert response.status_code == 401
@pytest.mark.asyncio
async def test_login_invalid_email_format(client: AsyncClient):
"""POST /api/v1/auth/login with invalid email format returns 422."""
response = await client.post("/api/v1/auth/login", json={
"email": "not-an-email",
"password": "SomePassword!",
})
assert response.status_code == 422
@pytest.mark.asyncio
async def test_refresh_valid_token(client: AsyncClient, admin_user: User):
"""POST /api/v1/auth/refresh with valid refresh token returns new tokens."""
refresh_token = create_refresh_token(
user_id=str(admin_user.id),
role="admin",
email=admin_user.email,
lang=admin_user.language,
)
response = await client.post("/api/v1/auth/refresh", json={
"refresh_token": refresh_token,
})
assert response.status_code == 200
data = response.json()
assert "access_token" in data
assert "refresh_token" in data
assert data["token_type"] == "bearer"
@pytest.mark.asyncio
async def test_refresh_invalid_token(client: AsyncClient):
"""POST /api/v1/auth/refresh with invalid token returns 401."""
response = await client.post("/api/v1/auth/refresh", json={
"refresh_token": "invalid.token.here",
})
assert response.status_code == 401
@pytest.mark.asyncio
async def test_refresh_access_token_rejected(client: AsyncClient, admin_user: User):
"""POST /api/v1/auth/refresh with an access token (not refresh) returns 401."""
access_token = create_access_token(
user_id=str(admin_user.id),
role="admin",
email=admin_user.email,
lang=admin_user.language,
)
response = await client.post("/api/v1/auth/refresh", json={
"refresh_token": access_token,
})
assert response.status_code == 401
@pytest.mark.asyncio
async def test_get_me_with_valid_token(client: AsyncClient, admin_user: User, admin_token: str):
"""GET /api/v1/auth/me with valid JWT returns 200 + user object."""
response = await client.get(
"/api/v1/auth/me",
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == 200
data = response.json()
assert data["email"] == "admin@test.com"
assert data["role"] == "admin"
assert data["is_active"] is True
assert "password_hash" not in data
@pytest.mark.asyncio
async def test_get_me_without_token(client: AsyncClient):
"""GET /api/v1/auth/me without token returns 401."""
response = await client.get("/api/v1/auth/me")
assert response.status_code == 401
@pytest.mark.asyncio
async def test_get_me_with_invalid_token(client: AsyncClient):
"""GET /api/v1/auth/me with invalid token returns 401."""
response = await client.get(
"/api/v1/auth/me",
headers={"Authorization": "Bearer invalid.token.here"},
)
assert response.status_code == 401
@pytest.mark.asyncio
async def test_get_me_with_refresh_token(client: AsyncClient, admin_user: User):
"""GET /api/v1/auth/me with a refresh token (not access) returns 401."""
refresh_token = create_refresh_token(
user_id=str(admin_user.id),
role="admin",
email=admin_user.email,
lang=admin_user.language,
)
response = await client.get(
"/api/v1/auth/me",
headers={"Authorization": f"Bearer {refresh_token}"},
)
assert response.status_code == 401
+348
View File
@@ -0,0 +1,348 @@
"""Direct unit tests for auth_service functions."""
import uuid
import pytest
from sqlalchemy.ext.asyncio import (
AsyncSession,
async_sessionmaker,
create_async_engine,
)
from app.database import Base
from app.models.user import User, UserRole
from app.services.auth_service import (
authenticate_user,
create_user,
deactivate_user,
generate_token_pair,
get_user_by_email,
get_user_by_id,
hash_password,
list_users,
refresh_access_token,
update_user,
verify_password,
)
from app.utils.jwt import create_refresh_token
TEST_DATABASE_URL = (
"postgresql+asyncpg://erp_test_user:testpass@localhost:5432/erp_test"
)
@pytest.fixture
def event_loop():
import asyncio
loop = asyncio.new_event_loop()
yield loop
loop.close()
@pytest.fixture
async def engine():
eng = create_async_engine(TEST_DATABASE_URL, echo=False, pool_pre_ping=True)
async with eng.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield eng
async with eng.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
await eng.dispose()
@pytest.fixture
async def session_factory(engine):
return async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
@pytest.fixture
async def session(session_factory):
async with session_factory() as s:
yield s
@pytest.mark.asyncio
async def test_hash_and_verify_password():
hashed = hash_password("mypassword")
assert hashed != "mypassword"
assert verify_password("mypassword", hashed) is True
assert verify_password("wrongpassword", hashed) is False
@pytest.mark.asyncio
async def test_get_user_by_email_found(session):
user = User(
email="findme@test.com",
password_hash=hash_password("Test12345!"),
full_name="Find Me",
role=UserRole.admin,
)
session.add(user)
await session.commit()
await session.refresh(user)
found = await get_user_by_email(session, "findme@test.com")
assert found is not None
assert found.email == "findme@test.com"
@pytest.mark.asyncio
async def test_get_user_by_email_not_found(session):
found = await get_user_by_email(session, "nobody@test.com")
assert found is None
@pytest.mark.asyncio
async def test_get_user_by_id_found(session):
user = User(
email="byid@test.com",
password_hash=hash_password("Test12345!"),
full_name="By ID",
role=UserRole.verkaeufer,
)
session.add(user)
await session.commit()
await session.refresh(user)
found = await get_user_by_id(session, user.id)
assert found is not None
assert found.email == "byid@test.com"
@pytest.mark.asyncio
async def test_get_user_by_id_not_found(session):
found = await get_user_by_id(session, uuid.uuid4())
assert found is None
@pytest.mark.asyncio
async def test_authenticate_user_valid(session):
user = User(
email="auth@test.com",
password_hash=hash_password("Auth12345!"),
full_name="Auth User",
role=UserRole.admin,
is_active=True,
)
session.add(user)
await session.commit()
await session.refresh(user)
result = await authenticate_user(session, "auth@test.com", "Auth12345!")
assert result is not None
assert result.email == "auth@test.com"
@pytest.mark.asyncio
async def test_authenticate_user_wrong_password(session):
user = User(
email="wrongpw@test.com",
password_hash=hash_password("Correct123!"),
full_name="Wrong PW",
role=UserRole.admin,
is_active=True,
)
session.add(user)
await session.commit()
result = await authenticate_user(session, "wrongpw@test.com", "Wrong123!")
assert result is None
@pytest.mark.asyncio
async def test_authenticate_user_inactive(session):
user = User(
email="inactive2@test.com",
password_hash=hash_password("Test12345!"),
full_name="Inactive",
role=UserRole.admin,
is_active=False,
)
session.add(user)
await session.commit()
result = await authenticate_user(session, "inactive2@test.com", "Test12345!")
assert result is None
@pytest.mark.asyncio
async def test_authenticate_user_nonexistent(session):
result = await authenticate_user(session, "ghost@test.com", "Test12345!")
assert result is None
@pytest.mark.asyncio
async def test_generate_token_pair(session):
user = User(
email="token@test.com",
password_hash=hash_password("Test12345!"),
full_name="Token User",
role=UserRole.admin,
language="de",
)
session.add(user)
await session.commit()
await session.refresh(user)
tokens = generate_token_pair(user)
assert "access_token" in tokens
assert "refresh_token" in tokens
assert tokens["token_type"] == "bearer"
assert tokens["expires_in"] > 0
@pytest.mark.asyncio
async def test_refresh_access_token_valid(session):
user = User(
email="refresh@test.com",
password_hash=hash_password("Test12345!"),
full_name="Refresh User",
role=UserRole.admin,
language="de",
is_active=True,
)
session.add(user)
await session.commit()
await session.refresh(user)
rt = create_refresh_token(
user_id=str(user.id), role="admin", email=user.email, lang="de"
)
tokens = await refresh_access_token(session, rt)
assert tokens is not None
assert "access_token" in tokens
@pytest.mark.asyncio
async def test_refresh_access_token_invalid(session):
result = await refresh_access_token(session, "invalid.token.here")
assert result is None
@pytest.mark.asyncio
async def test_refresh_access_token_nonexistent_user(session):
fake_id = uuid.uuid4()
rt = create_refresh_token(
user_id=str(fake_id), role="admin", email="ghost@test.com", lang="de"
)
result = await refresh_access_token(session, rt)
assert result is None
@pytest.mark.asyncio
async def test_create_user_success(session):
user = await create_user(
session,
email="new@test.com",
password="NewUser123!",
full_name="New User",
role="verkaeufer",
language="en",
)
assert user.email == "new@test.com"
assert user.full_name == "New User"
assert user.role == UserRole.verkaeufer
assert user.language == "en"
assert user.is_active is True
assert user.password_hash != "NewUser123!"
@pytest.mark.asyncio
async def test_create_user_duplicate(session):
await create_user(
session,
email="dup@test.com",
password="First123!",
full_name="First",
)
await session.commit()
with pytest.raises(ValueError, match="already exists"):
await create_user(
session,
email="dup@test.com",
password="Second123!",
full_name="Second",
)
@pytest.mark.asyncio
async def test_list_users_pagination(session):
for i in range(5):
u = User(
email=f"list{i}@test.com",
password_hash=hash_password("Test12345!"),
full_name=f"User {i}",
role=UserRole.verkaeufer,
)
session.add(u)
await session.commit()
users, total = await list_users(session, page=1, page_size=3)
assert total == 5
assert len(users) == 3
users2, total2 = await list_users(session, page=2, page_size=3)
assert total2 == 5
assert len(users2) == 2
@pytest.mark.asyncio
async def test_update_user_success(session):
user = await create_user(
session,
email="update@test.com",
password="Update123!",
full_name="Before Update",
)
await session.commit()
updated = await update_user(
session, user.id, full_name="After Update", language="en"
)
assert updated is not None
assert updated.full_name == "After Update"
assert updated.language == "en"
@pytest.mark.asyncio
async def test_update_user_not_found(session):
result = await update_user(session, uuid.uuid4(), full_name="Nobody")
assert result is None
@pytest.mark.asyncio
async def test_update_user_role(session):
user = await create_user(
session,
email="role@test.com",
password="Role12345!",
full_name="Role User",
role="verkaeufer",
)
await session.commit()
updated = await update_user(session, user.id, role="admin")
assert updated is not None
assert updated.role == UserRole.admin
@pytest.mark.asyncio
async def test_deactivate_user_success(session):
user = await create_user(
session,
email="deactivate@test.com",
password="Deact123!",
full_name="Deactivate Me",
)
await session.commit()
result = await deactivate_user(session, user.id)
assert result is not None
assert result.is_active is False
@pytest.mark.asyncio
async def test_deactivate_user_not_found(session):
result = await deactivate_user(session, uuid.uuid4())
assert result is None
+30
View File
@@ -0,0 +1,30 @@
"""Tests for the health check endpoint."""
import pytest
from httpx import AsyncClient
@pytest.mark.asyncio
async def test_health_check(client: AsyncClient):
"""GET /api/v1/health returns 200 with status ok."""
response = await client.get("/api/v1/health")
assert response.status_code == 200
data = response.json()
assert data["status"] == "ok"
@pytest.mark.asyncio
async def test_health_no_auth_required(client: AsyncClient):
"""Health endpoint works without authentication."""
response = await client.get("/api/v1/health")
assert response.status_code == 200
@pytest.mark.asyncio
async def test_root_endpoint(client: AsyncClient):
"""Root endpoint returns app info."""
response = await client.get("/")
assert response.status_code == 200
data = response.json()
assert "name" in data
assert "version" in data
+169
View File
@@ -0,0 +1,169 @@
"""Tests for users endpoints: list, create, update, delete (admin only)."""
import uuid
import pytest
from httpx import AsyncClient
from app.models.user import User
@pytest.mark.asyncio
async def test_list_users_as_admin(admin_client: AsyncClient, admin_user: User):
"""GET /api/v1/users as admin returns 200 + paginated list."""
response = await admin_client.get("/api/v1/users/")
assert response.status_code == 200
data = response.json()
assert "items" in data
assert "total" in data
assert "page" in data
assert "page_size" in data
assert data["total"] >= 1
assert data["page"] == 1
assert data["page_size"] == 20
@pytest.mark.asyncio
async def test_list_users_as_non_admin(verkaeufer_client: AsyncClient):
"""GET /api/v1/users as verkaeufer returns 403."""
response = await verkaeufer_client.get("/api/v1/users/")
assert response.status_code == 403
data = response.json()
assert "error" in data["detail"]
@pytest.mark.asyncio
async def test_list_users_without_auth(client: AsyncClient):
"""GET /api/v1/users without token returns 401."""
response = await client.get("/api/v1/users/")
assert response.status_code == 401
@pytest.mark.asyncio
async def test_list_users_pagination(admin_client: AsyncClient, admin_user: User):
"""GET /api/v1/users?page=1&page_size=5 returns correct pagination."""
response = await admin_client.get("/api/v1/users/?page=1&page_size=5")
assert response.status_code == 200
data = response.json()
assert data["page"] == 1
assert data["page_size"] == 5
assert len(data["items"]) <= 5
@pytest.mark.asyncio
async def test_create_user_as_admin(admin_client: AsyncClient):
"""POST /api/v1/users as admin creates a new user, returns 201."""
response = await admin_client.post("/api/v1/users/", json={
"email": "newuser@test.com",
"password": "NewUser123!",
"full_name": "New User",
"role": "verkaeufer",
"language": "de",
})
assert response.status_code == 201
data = response.json()
assert data["email"] == "newuser@test.com"
assert data["full_name"] == "New User"
assert data["role"] == "verkaeufer"
assert data["is_active"] is True
assert "password_hash" not in data
assert "password" not in data
@pytest.mark.asyncio
async def test_create_user_as_non_admin(verkaeufer_client: AsyncClient):
"""POST /api/v1/users as verkaeufer returns 403."""
response = await verkaeufer_client.post("/api/v1/users/", json={
"email": "forbidden@test.com",
"password": "Forbidden123!",
"full_name": "Forbidden",
"role": "admin",
"language": "de",
})
assert response.status_code == 403
@pytest.mark.asyncio
async def test_create_user_duplicate_email(admin_client: AsyncClient, admin_user: User):
"""POST /api/v1/users with existing email returns 409."""
response = await admin_client.post("/api/v1/users/", json={
"email": "admin@test.com",
"password": "SomePassword123!",
"full_name": "Duplicate",
"role": "verkaeufer",
"language": "de",
})
assert response.status_code == 409
@pytest.mark.asyncio
async def test_create_user_short_password(admin_client: AsyncClient):
"""POST /api/v1/users with password < 8 chars returns 422."""
response = await admin_client.post("/api/v1/users/", json={
"email": "shortpw@test.com",
"password": "short",
"full_name": "Short PW",
"role": "verkaeufer",
"language": "de",
})
assert response.status_code == 422
@pytest.mark.asyncio
async def test_update_user_as_admin(admin_client: AsyncClient, admin_user: User, verkaeufer_user: User):
"""PUT /api/v1/users/:id as admin updates user fields, returns 200."""
response = await admin_client.put(
f"/api/v1/users/{verkaeufer_user.id}",
json={"full_name": "Updated Name", "language": "de"},
)
assert response.status_code == 200
data = response.json()
assert data["full_name"] == "Updated Name"
assert data["language"] == "de"
@pytest.mark.asyncio
async def test_update_user_nonexistent(admin_client: AsyncClient):
"""PUT /api/v1/users/:id with unknown id returns 404."""
fake_id = uuid.uuid4()
response = await admin_client.put(
f"/api/v1/users/{fake_id}",
json={"full_name": "Nobody"},
)
assert response.status_code == 404
@pytest.mark.asyncio
async def test_delete_user_soft_deactivate(admin_client: AsyncClient, verkaeufer_user: User):
"""DELETE /api/v1/users/:id soft-deletes (is_active=false), returns 200."""
response = await admin_client.delete(f"/api/v1/users/{verkaeufer_user.id}")
assert response.status_code == 200
data = response.json()
assert data["is_active"] is False
assert data["id"] == str(verkaeufer_user.id)
@pytest.mark.asyncio
async def test_delete_user_nonexistent(admin_client: AsyncClient):
"""DELETE /api/v1/users/:id with unknown id returns 404."""
fake_id = uuid.uuid4()
response = await admin_client.delete(f"/api/v1/users/{fake_id}")
assert response.status_code == 404
@pytest.mark.asyncio
async def test_delete_user_as_non_admin(verkaeufer_client: AsyncClient, admin_user: User):
"""DELETE /api/v1/users/:id as verkaeufer returns 403."""
response = await verkaeufer_client.delete(f"/api/v1/users/{admin_user.id}")
assert response.status_code == 403
@pytest.mark.asyncio
async def test_user_response_excludes_password_hash(admin_client: AsyncClient, admin_user: User):
"""User response never includes password_hash or password fields."""
response = await admin_client.get("/api/v1/users/")
assert response.status_code == 200
data = response.json()
for item in data["items"]:
assert "password_hash" not in item
assert "password" not in item