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
BIN
View File
Binary file not shown.
+5
View File
@@ -0,0 +1,5 @@
[run]
source = app
[report]
show_missing = true
View File
+55
View File
@@ -0,0 +1,55 @@
"""Application configuration using Pydantic BaseSettings.
All secrets and environment-dependent values are loaded from environment
variables or a local .env file. No hardcoded secrets in code.
"""
from functools import lru_cache
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
"""Central application settings loaded from env vars or .env file."""
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
extra="ignore",
)
DATABASE_URL: str = (
"postgresql+asyncpg://erp_test_user:testpass@localhost:5432/erp_test"
)
REDIS_URL: str = "redis://localhost:6379/0"
JWT_SECRET: str = "change-me-to-a-secure-random-string"
JWT_ALGORITHM: str = "HS256"
JWT_ACCESS_TTL_MINUTES: int = 15
JWT_REFRESH_TTL_DAYS: int = 7
CORS_ORIGINS: str = "http://localhost:3000,http://localhost:3001"
UPLOAD_DIR: str = "/tmp/uploads"
APP_NAME: str = "ERP Nutzfahrzeuge"
APP_ENV: str = "development"
@property
def cors_origins_list(self) -> list[str]:
"""Parse CORS_ORIGINS comma-separated string into a list."""
return [o.strip() for o in self.CORS_ORIGINS.split(",") if o.strip()]
@property
def access_token_ttl_seconds(self) -> int:
return self.JWT_ACCESS_TTL_MINUTES * 60
@property
def refresh_token_ttl_seconds(self) -> int:
return self.JWT_REFRESH_TTL_DAYS * 86400
@lru_cache
def get_settings() -> Settings:
"""Cached settings singleton."""
return Settings()
settings = get_settings()
+55
View File
@@ -0,0 +1,55 @@
"""Async SQLAlchemy database engine, session factory, and base model."""
from sqlalchemy.ext.asyncio import (
AsyncSession,
async_sessionmaker,
create_async_engine,
)
from sqlalchemy.orm import DeclarativeBase
from app.config import settings
class Base(DeclarativeBase):
"""Declarative base for all SQLAlchemy models."""
pass
engine = create_async_engine(
settings.DATABASE_URL,
echo=(settings.APP_ENV == "development"),
pool_pre_ping=True,
pool_size=10,
max_overflow=20,
)
async_session_factory = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
)
async def get_db() -> AsyncSession:
"""FastAPI dependency that yields an async DB session."""
async with async_session_factory() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
finally:
await session.close()
async def init_db():
"""Create all tables. Used for testing and first-run setup."""
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async def drop_db():
"""Drop all tables. Used in test teardown."""
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
+103
View File
@@ -0,0 +1,103 @@
"""FastAPI dependencies: pagination, current user extraction, RBAC role enforcement.
"""
import uuid
from typing import Optional
from fastapi import Depends, HTTPException, Query, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models.user import User, UserRole
from app.services.auth_service import get_user_by_id
from app.utils.jwt import verify_access_token
security = HTTPBearer(auto_error=False)
def get_pagination(
page: int = Query(1, ge=1, description="Page number (1-indexed)"),
page_size: int = Query(20, ge=1, le=100, description="Items per page"),
) -> dict:
"""Common pagination parameters."""
return {"page": page, "page_size": page_size}
async def get_current_user(
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
db: AsyncSession = Depends(get_db),
) -> User:
"""Extract and verify the JWT bearer token, returning the current user.
Raises 401 if token is missing, invalid, or the user is not found / inactive.
"""
if credentials is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={"error": {"code": "UNAUTHORIZED", "message": "Missing authentication token"}},
)
token = credentials.credentials
payload = verify_access_token(token)
if payload is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={"error": {"code": "INVALID_TOKEN", "message": "Invalid or expired access token"}},
)
user_id_str = payload.get("sub")
if not user_id_str:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={"error": {"code": "INVALID_TOKEN", "message": "Token missing subject"}},
)
try:
user_uuid = uuid.UUID(user_id_str)
except (ValueError, TypeError):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={"error": {"code": "INVALID_TOKEN", "message": "Invalid user ID in token"}},
)
user = await get_user_by_id(db, user_uuid)
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={"error": {"code": "USER_NOT_FOUND", "message": "User not found"}},
)
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={"error": {"code": "ACCOUNT_DISABLED", "message": "Account is deactivated"}},
)
return user
def require_role(allowed_roles: list[str]):
"""RBAC dependency factory: enforce that the current user has one of the allowed roles.
Usage:
@router.get("/", dependencies=[Depends(require_role(["admin"]))])
or:
async def handler(user: User = Depends(require_role(["admin"]))):
"""
async def role_checker(user: User = Depends(get_current_user)) -> User:
user_role = user.role.value if isinstance(user.role, UserRole) else str(user.role)
if user_role not in allowed_roles:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={
"error": {
"code": "FORBIDDEN",
"message": f"Role '{user_role}' is not permitted. Required: {allowed_roles}",
}
},
)
return user
return role_checker
+57
View File
@@ -0,0 +1,57 @@
"""FastAPI application entry point.
Configures CORS, registers routers under /api/v1, exposes health endpoint.
"""
from contextlib import asynccontextmanager
from fastapi import APIRouter, FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.config import settings
from app.routers import auth, users
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Application startup and shutdown lifecycle."""
# Startup: could run alembic migrations here in production
yield
# Shutdown: cleanup resources
app = FastAPI(
title=settings.APP_NAME,
description="ERP Nutzfahrzeuge Backend API",
version="1.0.0",
lifespan=lifespan,
)
# CORS configuration from settings
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins_list,
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"],
allow_headers=["*"],
)
# API v1 router
api_v1_router = APIRouter(prefix="/api/v1")
api_v1_router.include_router(auth.router)
api_v1_router.include_router(users.router)
# Health endpoint (no auth required)
@api_v1_router.get("/health", tags=["health"])
async def health_check():
"""Health check endpoint."""
return {"status": "ok"}
app.include_router(api_v1_router)
@app.get("/")
async def root():
"""Root redirect info."""
return {"name": settings.APP_NAME, "version": "1.0.0", "docs": "/docs"}
View File
+77
View File
@@ -0,0 +1,77 @@
"""SQLAlchemy User model with UUID primary key."""
import enum
import uuid
from datetime import datetime, timezone
from sqlalchemy import Boolean, DateTime, Enum, String, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class UserRole(str, enum.Enum):
"""User roles for RBAC."""
admin = "admin"
verkaeufer = "verkaeufer"
buchhaltung = "buchhaltung"
class User(Base):
"""User entity for authentication and authorization."""
__tablename__ = "users"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
primary_key=True,
default=uuid.uuid4,
)
email: Mapped[str] = mapped_column(
String(255), unique=True, nullable=False, index=True,
)
password_hash: Mapped[str] = mapped_column(
String(255), nullable=False,
)
full_name: Mapped[str] = mapped_column(
String(200), nullable=False,
)
role: Mapped[UserRole] = mapped_column(
Enum(UserRole, name="user_role"),
nullable=False,
default=UserRole.verkaeufer,
)
language: Mapped[str] = mapped_column(
String(5), nullable=False, default="de",
)
is_active: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=True,
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
server_default=func.now(),
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
server_default=func.now(),
onupdate=func.now(),
)
def __repr__(self) -> str:
return f"<User id={self.id} email={self.email} role={self.role}>"
def to_dict(self) -> dict:
"""Serialize user for API responses (excludes password_hash)."""
return {
"id": str(self.id),
"email": self.email,
"full_name": self.full_name,
"role": self.role.value if isinstance(self.role, UserRole) else self.role,
"language": self.language,
"is_active": self.is_active,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}
View File
+68
View File
@@ -0,0 +1,68 @@
"""Auth router: login, token refresh, current user profile."""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.dependencies import get_current_user
from app.models.user import User
from app.schemas.user import (
LoginRequest,
RefreshRequest,
TokenResponse,
UserResponse,
)
from app.services.auth_service import (
authenticate_user,
generate_token_pair,
refresh_access_token,
)
router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/login", response_model=TokenResponse, status_code=status.HTTP_200_OK)
async def login(
body: LoginRequest,
db: AsyncSession = Depends(get_db),
):
"""Authenticate user and return JWT access + refresh tokens."""
user = await authenticate_user(db, body.email, body.password)
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={
"error": {
"code": "INVALID_CREDENTIALS",
"message": "Invalid email or password",
}
},
)
tokens = generate_token_pair(user)
return TokenResponse(**tokens)
@router.post("/refresh", response_model=TokenResponse, status_code=status.HTTP_200_OK)
async def refresh_token(
body: RefreshRequest,
db: AsyncSession = Depends(get_db),
):
"""Exchange a valid refresh token for a new access + refresh token pair."""
tokens = await refresh_access_token(db, body.refresh_token)
if tokens is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={
"error": {
"code": "INVALID_REFRESH_TOKEN",
"message": "Invalid or expired refresh token",
}
},
)
return TokenResponse(**tokens)
@router.get("/me", response_model=UserResponse, status_code=status.HTTP_200_OK)
async def get_me(current_user: User = Depends(get_current_user)):
"""Return the currently authenticated user's profile."""
return UserResponse.model_validate(current_user)
+106
View File
@@ -0,0 +1,106 @@
"""Users router: admin-only user management (list, create, update, deactivate)."""
import uuid
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.dependencies import get_current_user, get_pagination, require_role
from app.models.user import User
from app.schemas.user import (
UserCreate,
UserListResponse,
UserResponse,
UserUpdate,
)
from app.services.auth_service import (
create_user as svc_create_user,
deactivate_user as svc_deactivate_user,
get_user_by_id,
list_users as svc_list_users,
update_user as svc_update_user,
)
router = APIRouter(prefix="/users", tags=["users"])
@router.get("/", response_model=UserListResponse, status_code=status.HTTP_200_OK)
async def list_users(
db: AsyncSession = Depends(get_db),
pagination: dict = Depends(get_pagination),
admin: User = Depends(require_role(["admin"])),
):
"""List all users with pagination. Admin only."""
users, total = await svc_list_users(
db, page=pagination["page"], page_size=pagination["page_size"]
)
return UserListResponse(
items=[UserResponse.model_validate(u) for u in users],
total=total,
page=pagination["page"],
page_size=pagination["page_size"],
)
@router.post("/", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
async def create_user(
body: UserCreate,
db: AsyncSession = Depends(get_db),
admin: User = Depends(require_role(["admin"])),
):
"""Create a new user. Admin only."""
try:
user = await svc_create_user(
db,
email=body.email,
password=body.password,
full_name=body.full_name,
role=body.role,
language=body.language,
)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail={"error": {"code": "DUPLICATE_EMAIL", "message": str(exc)}},
)
return UserResponse.model_validate(user)
@router.put("/{user_id}", response_model=UserResponse, status_code=status.HTTP_200_OK)
async def update_user(
user_id: uuid.UUID,
body: UserUpdate,
db: AsyncSession = Depends(get_db),
admin: User = Depends(require_role(["admin"])),
):
"""Update a user's fields. Admin only."""
updates = body.model_dump(exclude_unset=True)
if not updates:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"error": {"code": "NO_FIELDS", "message": "No fields to update"}},
)
user = await svc_update_user(db, user_id, **updates)
if user is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"error": {"code": "USER_NOT_FOUND", "message": "User not found"}},
)
return UserResponse.model_validate(user)
@router.delete("/{user_id}", response_model=UserResponse, status_code=status.HTTP_200_OK)
async def delete_user(
user_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
admin: User = Depends(require_role(["admin"])),
):
"""Deactivate a user (soft delete). Admin only."""
user = await svc_deactivate_user(db, user_id)
if user is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"error": {"code": "USER_NOT_FOUND", "message": "User not found"}},
)
return UserResponse.model_validate(user)
View File
+80
View File
@@ -0,0 +1,80 @@
"""Pydantic schemas for user-related request and response bodies."""
import uuid
from datetime import datetime
from typing import Literal, Optional
from pydantic import BaseModel, ConfigDict, EmailStr, Field
class LoginRequest(BaseModel):
"""POST /api/v1/auth/login request body."""
email: EmailStr
password: str = Field(..., min_length=1)
class TokenResponse(BaseModel):
"""JWT token pair returned after login or refresh."""
access_token: str
refresh_token: str
token_type: str = "bearer"
expires_in: int = Field(..., description="Access token TTL in seconds")
class RefreshRequest(BaseModel):
"""POST /api/v1/auth/refresh request body."""
refresh_token: str
class UserBase(BaseModel):
"""Base user fields shared across schemas."""
email: EmailStr
full_name: str = Field(..., min_length=1, max_length=200)
role: Literal["admin", "verkaeufer", "buchhaltung"] = "verkaeufer"
language: str = Field(default="de", max_length=5)
class UserCreate(UserBase):
"""POST /api/v1/users request body."""
password: str = Field(..., min_length=8, max_length=128)
class UserUpdate(BaseModel):
"""PUT /api/v1/users/:id request body (all fields optional)."""
email: Optional[EmailStr] = None
full_name: Optional[str] = Field(None, min_length=1, max_length=200)
role: Optional[Literal["admin", "verkaeufer", "buchhaltung"]] = None
language: Optional[str] = Field(None, max_length=5)
is_active: Optional[bool] = None
class UserResponse(BaseModel):
"""User response schema (never exposes password_hash)."""
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
email: str
full_name: str
role: str
language: str
is_active: bool
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
class UserListResponse(BaseModel):
"""Paginated user list response."""
items: list[UserResponse]
total: int
page: int
page_size: int
class ErrorResponse(BaseModel):
"""Standard error response format."""
error: dict
class HealthResponse(BaseModel):
"""Health check response."""
status: str = "ok"
View File
+178
View File
@@ -0,0 +1,178 @@
"""Authentication service: password hashing, login, token refresh, user lookup."""
import uuid
from typing import Optional
from passlib.context import CryptContext
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.user import User, UserRole
from app.utils.jwt import (
create_access_token,
create_refresh_token,
verify_refresh_token,
)
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(password: str) -> str:
"""Hash a plaintext password using bcrypt."""
return pwd_context.hash(password)
def verify_password(plaintext: str, hashed: str) -> bool:
"""Verify a plaintext password against a bcrypt hash."""
return pwd_context.verify(plaintext, hashed)
async def get_user_by_email(db: AsyncSession, email: str) -> Optional[User]:
"""Fetch a user by email address."""
result = await db.execute(select(User).where(User.email == email))
return result.scalar_one_or_none()
async def get_user_by_id(db: AsyncSession, user_id: uuid.UUID) -> Optional[User]:
"""Fetch a user by UUID."""
result = await db.execute(select(User).where(User.id == user_id))
return result.scalar_one_or_none()
async def authenticate_user(db: AsyncSession, email: str, password: str) -> Optional[User]:
"""Authenticate a user by email and password."""
user = await get_user_by_email(db, email)
if user is None:
return None
if not user.is_active:
return None
if not verify_password(password, user.password_hash):
return None
return user
def generate_token_pair(user: User) -> dict:
"""Create access + refresh JWT tokens for a given user."""
role_val = user.role.value if isinstance(user.role, UserRole) else str(user.role)
access_token = create_access_token(
user_id=str(user.id),
role=role_val,
email=user.email,
lang=user.language,
)
refresh_token = create_refresh_token(
user_id=str(user.id),
role=role_val,
email=user.email,
lang=user.language,
)
from app.config import settings
return {
"access_token": access_token,
"refresh_token": refresh_token,
"token_type": "bearer",
"expires_in": settings.access_token_ttl_seconds,
}
async def refresh_access_token(db: AsyncSession, refresh_token: str) -> Optional[dict]:
"""Verify a refresh token and issue a new token pair."""
payload = verify_refresh_token(refresh_token)
if payload is None:
return None
user_id_str = payload.get("sub")
if not user_id_str:
return None
try:
user_uuid = uuid.UUID(user_id_str)
except (ValueError, TypeError):
return None
user = await get_user_by_id(db, user_uuid)
if user is None or not user.is_active:
return None
return generate_token_pair(user)
async def create_user(
db: AsyncSession,
email: str,
password: str,
full_name: str,
role: str = "verkaeufer",
language: str = "de",
) -> User:
"""Create a new user with a bcrypt-hashed password."""
existing = await get_user_by_email(db, email)
if existing is not None:
raise ValueError(f"User with email {email} already exists")
user = User(
email=email,
password_hash=hash_password(password),
full_name=full_name,
role=UserRole(role),
language=language,
is_active=True,
)
db.add(user)
await db.flush()
await db.refresh(user)
return user
async def list_users(
db: AsyncSession,
page: int = 1,
page_size: int = 20,
) -> tuple[list[User], int]:
"""List users with pagination. Returns (users, total_count)."""
count_result = await db.execute(select(func.count(User.id)))
total = count_result.scalar() or 0
offset = (page - 1) * page_size
result = await db.execute(
select(User)
.order_by(User.created_at.desc())
.offset(offset)
.limit(page_size)
)
users = list(result.scalars().all())
return users, total
async def update_user(
db: AsyncSession,
user_id: uuid.UUID,
**updates,
) -> Optional[User]:
"""Update user fields. Returns updated user or None if not found."""
user = await get_user_by_id(db, user_id)
if user is None:
return None
for field, value in updates.items():
if field == "role" and value is not None:
value = UserRole(value)
if field == "password":
user.password_hash = hash_password(value)
elif hasattr(user, field) and value is not None:
setattr(user, field, value)
await db.flush()
await db.refresh(user)
return user
async def deactivate_user(db: AsyncSession, user_id: uuid.UUID) -> Optional[User]:
"""Soft-delete a user by setting is_active=False. Returns user or None."""
user = await get_user_by_id(db, user_id)
if user is None:
return None
user.is_active = False
await db.flush()
await db.refresh(user)
return user
View File
+83
View File
@@ -0,0 +1,83 @@
"""JWT utility functions for token creation and verification."""
from datetime import datetime, timedelta, timezone
from typing import Optional
from jose import JWTError, jwt
from app.config import settings
def create_access_token(
user_id: str,
role: str,
email: str,
lang: str = "de",
) -> str:
"""Create a short-lived access JWT."""
now = datetime.now(timezone.utc)
expire = now + timedelta(seconds=settings.access_token_ttl_seconds)
payload = {
"sub": str(user_id),
"role": role,
"email": email,
"lang": lang,
"exp": expire,
"iat": now,
"type": "access",
}
return jwt.encode(payload, settings.JWT_SECRET, algorithm=settings.JWT_ALGORITHM)
def create_refresh_token(
user_id: str,
role: str,
email: str,
lang: str = "de",
) -> str:
"""Create a long-lived refresh JWT."""
now = datetime.now(timezone.utc)
expire = now + timedelta(seconds=settings.refresh_token_ttl_seconds)
payload = {
"sub": str(user_id),
"role": role,
"email": email,
"lang": lang,
"exp": expire,
"iat": now,
"type": "refresh",
}
return jwt.encode(payload, settings.JWT_SECRET, algorithm=settings.JWT_ALGORITHM)
def decode_token(token: str) -> Optional[dict]:
"""Decode and verify a JWT token. Returns payload or None."""
try:
payload = jwt.decode(
token,
settings.JWT_SECRET,
algorithms=[settings.JWT_ALGORITHM],
)
return payload
except JWTError:
return None
def verify_access_token(token: str) -> Optional[dict]:
"""Verify an access token specifically (checks type claim)."""
payload = decode_token(token)
if payload is None:
return None
if payload.get("type") != "access":
return None
return payload
def verify_refresh_token(token: str) -> Optional[dict]:
"""Verify a refresh token specifically (checks type claim)."""
payload = decode_token(token)
if payload is None:
return None
if payload.get("type") != "refresh":
return None
return payload
+5
View File
@@ -0,0 +1,5 @@
[pytest]
asyncio_mode = auto
testpaths = tests
filterwarnings =
ignore::DeprecationWarning
+14
View File
@@ -0,0 +1,14 @@
fastapi>=0.115.0
uvicorn[standard]>=0.32.0
sqlalchemy[asyncio]>=2.0.0
asyncpg>=0.30.0
pydantic-settings>=2.0.0
python-jose[cryptography]>=3.3.0
passlib[bcrypt]>=1.7.4
redis>=5.0.0
python-multipart>=0.0.12
pytest>=8.0.0
pytest-asyncio>=0.24.0
httpx>=0.27.0
pytest-cov>=5.0.0
aiosqlite>=0.20.0
+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