diff --git a/api/pytest.ini b/api/pytest.ini new file mode 100644 index 0000000..d8d909d --- /dev/null +++ b/api/pytest.ini @@ -0,0 +1,9 @@ +[pytest] +pythonpath = . +testpaths = tests +python_files = test_*.py +python_classes = Test* +python_functions = test_* +asyncio_mode = auto +addopts = --verbose --tb=short --capture=no --strict-markers +norecursedirs = .git __pycache__ .venv env build dist diff --git a/api/requirements-dev.txt b/api/requirements-dev.txt new file mode 100644 index 0000000..bc6fc87 --- /dev/null +++ b/api/requirements-dev.txt @@ -0,0 +1,16 @@ +fastapi==0.111.0 +uvicorn[standard]==0.30.1 +pydantic==2.11.7 +pydantic[email] +pydantic-settings==2.10.1 +sqlalchemy>=2.0.32,<2.1 +python-jose[cryptography]==3.3.0 +argon2-cffi==23.1.0 +python-multipart==0.0.9 +python-dotenv==1.0.0 +httpx==0.27.0 +alembic==1.16.1 +aiosqlite==0.20.0 +asyncpg==0.30.0 +pytest==8.3.2 +pytest-asyncio==0.24.0 diff --git a/api/requirements.txt b/api/requirements.txt index 89b03ec..a7407f8 100644 Binary files a/api/requirements.txt and b/api/requirements.txt differ diff --git a/api/run_server.py b/api/run_server.py index 5d466d4..43774be 100644 --- a/api/run_server.py +++ b/api/run_server.py @@ -1,11 +1,11 @@ import uvicorn -from src import app, get_settings + +from src.main import app, settings if __name__ == "__main__": - settings = get_settings() uvicorn.run( app, - host=settings.server.HOST, - port=settings.server.PORT, - lifespan="on" + host=settings.HOST, + port=settings.PORT, + lifespan="on", ) diff --git a/api/src/__init__.py b/api/src/__init__.py index cc26252..8b13789 100644 --- a/api/src/__init__.py +++ b/api/src/__init__.py @@ -1,7 +1 @@ -from .app import app -from .conf import get_settings -__all__ = [ - "app", - "get_settings" -] \ No newline at end of file diff --git a/api/src/api/__init__.py b/api/src/api/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/api/src/api/__init__.py @@ -0,0 +1 @@ + diff --git a/api/src/api/v1/__init__.py b/api/src/api/v1/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/api/src/api/v1/__init__.py @@ -0,0 +1 @@ + diff --git a/api/src/api/v1/auth.py b/api/src/api/v1/auth.py new file mode 100644 index 0000000..900158f --- /dev/null +++ b/api/src/api/v1/auth.py @@ -0,0 +1,84 @@ +from fastapi import APIRouter, Depends, HTTPException, Request, status +from sqlalchemy.ext.asyncio import AsyncSession + +from src.core.config import settings +from src.db.session import get_db +from src.domain.schemas import LoginRequest, RefreshRequest, Token +from src.services.auth_service import AuthService + +if not settings.DEBUG: + from raisa_fastapi_protected_api import UserInfo, get_user_dependency + +router = APIRouter(prefix="/auth", tags=["auth"]) + + +if settings.DEBUG: + + @router.post("/login", response_model=Token) + async def login( + login_data: LoginRequest, + db: AsyncSession = Depends(get_db), + ): + auth_service = AuthService(db) + token = await auth_service.authenticate_user(login_data.username, login_data.password) + if not token: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Неверное имя пользователя или пароль", + headers={"WWW-Authenticate": "Bearer"}, + ) + return token + +else: + + @router.post("/login", response_model=Token) + async def login( + login_data: LoginRequest, + db: AsyncSession = Depends(get_db), + user: UserInfo = Depends(get_user_dependency), + ): + auth_service = AuthService(db) + token = await auth_service.authenticate_user_via_email(user.email) + if not token: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Неверное имя пользователя или пароль", + headers={"WWW-Authenticate": "Bearer"}, + ) + return token + + +@router.post("/login-form", response_model=Token) +async def login_form(request: Request, db: AsyncSession = Depends(get_db)): + form = await request.form() + username = form.get("username") + password = form.get("password") + if not username or not password: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="username/password required", + ) + auth_service = AuthService(db) + token = await auth_service.authenticate_user(username, password) + if not token: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Неверное имя пользователя или пароль", + ) + return token + + +@router.post("/refresh", response_model=Token) +async def refresh_token( + refresh_data: RefreshRequest, + db: AsyncSession = Depends(get_db), +): + auth_service = AuthService(db) + token = await auth_service.refresh_token(refresh_data.refresh_token) + if not token: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Недействительный refresh токен", + headers={"WWW-Authenticate": "Bearer"}, + ) + return token diff --git a/api/src/api/v1/deps.py b/api/src/api/v1/deps.py new file mode 100644 index 0000000..21266fd --- /dev/null +++ b/api/src/api/v1/deps.py @@ -0,0 +1,99 @@ +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from sqlalchemy.ext.asyncio import AsyncSession + +from src.core.config import settings +from src.core.security import verify_token +from src.db.session import get_db +from src.domain.models import UserRole, Users +from src.repository.user_repository import UserRepository + +security = HTTPBearer() + + +if settings.DEBUG: + + async def get_current_user( + credentials: HTTPAuthorizationCredentials = Depends(security), + db: AsyncSession = Depends(get_db), + ) -> Users: + token = credentials.credentials + payload = verify_token(token) + if payload is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Недействительный токен", + headers={"WWW-Authenticate": "Bearer"}, + ) + + username: str | None = payload.get("sub") + if username is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Недействительный токен", + headers={"WWW-Authenticate": "Bearer"}, + ) + + user_repo = UserRepository(db) + user = await user_repo.get_by_username(username) + if user is None: + user = await user_repo.get_by_email(username) + if user is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Пользователь не найден", + headers={"WWW-Authenticate": "Bearer"}, + ) + return user + +else: + from raisa_fastapi_protected_api import UserInfo, get_user_dependency + + async def get_current_user( + user: UserInfo = Depends(get_user_dependency), + db: AsyncSession = Depends(get_db), + ) -> Users: + user_repo = UserRepository(db) + db_user = await user_repo.get_by_email(user.email) + if db_user is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Пользователь не найден", + headers={"WWW-Authenticate": "Bearer"}, + ) + return db_user + + +async def get_current_active_user(current_user: Users = Depends(get_current_user)) -> Users: + return current_user + + +def require_admin(current_user: Users = Depends(get_current_active_user)) -> Users: + if current_user.role_id != UserRole.ADMIN: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Недостаточно прав", + ) + return current_user + + +def require_executor(current_user: Users = Depends(get_current_active_user)) -> Users: + if current_user.role_id not in [ + UserRole.ADMIN, + UserRole.EXECUTOR_DFIP, + UserRole.EXECUTOR_RF, + ]: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Недостаточно прав", + ) + return current_user + + +def require_executor_dfip(current_user: Users = Depends(get_current_active_user)) -> Users: + if current_user.role_id not in [UserRole.ADMIN, UserRole.EXECUTOR_DFIP]: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Недостаточно прав", + ) + return current_user diff --git a/api/src/api/v1/router.py b/api/src/api/v1/router.py new file mode 100644 index 0000000..388ff1d --- /dev/null +++ b/api/src/api/v1/router.py @@ -0,0 +1,7 @@ +from fastapi import APIRouter + +from src.api.v1 import auth, users + +api_router = APIRouter() +api_router.include_router(auth.router) +api_router.include_router(users.router) diff --git a/api/src/api/v1/users.py b/api/src/api/v1/users.py new file mode 100644 index 0000000..c3f7f12 --- /dev/null +++ b/api/src/api/v1/users.py @@ -0,0 +1,138 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession + +from src.api.v1.deps import get_current_active_user, require_admin, require_executor_dfip +from src.db.session import get_db +from src.domain.models import Users +from src.domain.schemas import User as UserSchema +from src.domain.schemas import ( + SSPIDList, + ResponseBase, + UserCreate, + UserListResponse, + UserResponse, + UserSSPLinkBase, + UserSSPLinkResponse, + UserUpdate, +) +from src.services.user_service import UserService + +router = APIRouter(prefix="/users", tags=["users"]) + + +@router.get("/me", response_model=UserSchema) +async def get_current_user_info(current_user: Users = Depends(get_current_active_user)): + return current_user + + +@router.get("/", response_model=UserListResponse) +async def get_users( + skip: int = 0, + limit: int = 100, + db: AsyncSession = Depends(get_db), + current_user: Users = Depends(require_admin), +): + user_service = UserService(db) + users = await user_service.get_all(current_user, skip=skip, limit=limit) + return UserListResponse(success=True, message="Список пользователей", result=users) + + +@router.get("/dfip-many-ssp/", response_model=UserSSPLinkResponse) +async def get_many_ssp( + user_id: int, + db: AsyncSession = Depends(get_db), + current_user: Users = Depends(require_executor_dfip), +): + user_service = UserService(db) + ssp_list = await user_service.get_many_ssp(user_id, current_user) + return UserSSPLinkResponse( + success=True, + message="OK", + result=UserSSPLinkBase(user_id=user_id, ssp=ssp_list), + ) + + +@router.put("/dfip-many-ssp/", response_model=ResponseBase) +async def set_many_ssp( + user_id: int, + ssp_ids_data: SSPIDList, + db: AsyncSession = Depends(get_db), + current_user: Users = Depends(require_admin), +): + user_service = UserService(db) + success = await user_service.set_many_ssp(user_id, ssp_ids_data.ssp_ids, current_user) + return ResponseBase( + success=success, + message="OK" if success else "Ошибка при прикреплении ССП/РФ", + ) + + +@router.delete("/dfip-many-ssp/", response_model=ResponseBase) +async def unset_many_ssp( + user_id: int, + ssp_ids_data: SSPIDList, + db: AsyncSession = Depends(get_db), + current_user: Users = Depends(require_admin), +): + user_service = UserService(db) + success = await user_service.unset_many_ssp( + user_id, ssp_ids_data.ssp_ids, current_user + ) + return ResponseBase( + success=success, + message="OK" if success else "Ошибка при откреплении ССП/РФ", + ) + + +@router.get("/{user_id}", response_model=UserSchema) +async def get_user( + user_id: int, + db: AsyncSession = Depends(get_db), + current_user: Users = Depends(require_admin), +): + user_service = UserService(db) + user = await user_service.get(user_id, current_user) + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Пользователь не найден", + ) + return user + + +@router.put("/", response_model=UserResponse) +async def create_user( + user_data: UserCreate, + db: AsyncSession = Depends(get_db), + current_user: Users = Depends(require_admin), +): + user_service = UserService(db) + user = await user_service.create_user(user_data=user_data, creator=current_user) + return UserResponse(success=True, message="Пользователь создан", result=user) + + +@router.patch("/{user_id}", response_model=UserResponse) +async def update_user( + user_id: int, + user_data: UserUpdate, + db: AsyncSession = Depends(get_db), + current_user: Users = Depends(require_admin), +): + user_service = UserService(db) + user = await user_service.update_user( + user_id=user_id, + user_data=user_data, + user=current_user, + ) + return UserResponse(success=True, message="Пользователь обновлен", result=user) + + +@router.delete("/{user_id}", response_model=UserResponse) +async def delete_user( + user_id: int, + db: AsyncSession = Depends(get_db), + current_user: Users = Depends(require_admin), +): + user_service = UserService(db) + await user_service.delete_user(user_id=user_id, user=current_user) + return UserResponse(success=True, message="Пользователь удалён") diff --git a/api/src/app.py b/api/src/app.py deleted file mode 100644 index 5a8b160..0000000 --- a/api/src/app.py +++ /dev/null @@ -1,50 +0,0 @@ -import os - -from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware -from fastapi import FastAPI -from fastapi.middleware.cors import CORSMiddleware -from fastapi.staticfiles import StaticFiles -from datetime import datetime, timezone - -from .conf import get_settings -from .routes import api_route - -settings = get_settings() - -VERSION = "1.0.0" -UP_TIME = datetime.now(timezone.utc) - -app = FastAPI( - title="Fast API", - version=VERSION, - root_path=settings.ROOT_PATH, -) - -@app.get( - "/healthcheck", - status_code=200 -) -async def healthcheck(): - return { - "uptime": UP_TIME, - "version": VERSION - } - -app.add_middleware(ProxyHeadersMiddleware, trusted_hosts=["*"]) - -app.add_middleware( - CORSMiddleware, - allow_origins=["*"], - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], -) - -#Создание каталога для web приложения -os.makedirs(os.path.join('web'), exist_ok=True) - -#Подключаем все ендпоинты и роуты до маунта статических файлов -app.include_router(api_route) - -#Подключение собранного web приложения -app.mount("/", StaticFiles(directory="web", html=True), name="web") \ No newline at end of file diff --git a/api/src/conf/__init__.py b/api/src/conf/__init__.py deleted file mode 100644 index 3b738e8..0000000 --- a/api/src/conf/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from .settings import get_settings - -__all__ = [ - "get_settings" -] \ No newline at end of file diff --git a/api/src/conf/models.py b/api/src/conf/models.py deleted file mode 100644 index e38a908..0000000 --- a/api/src/conf/models.py +++ /dev/null @@ -1,9 +0,0 @@ -from pydantic import BaseModel - - -class ServerSettings(BaseModel): - """ - Настройки сервера uvicorn - """ - HOST: str = '0.0.0.0' - PORT: int = 8000 \ No newline at end of file diff --git a/api/src/conf/settings.py b/api/src/conf/settings.py deleted file mode 100644 index 1d96d08..0000000 --- a/api/src/conf/settings.py +++ /dev/null @@ -1,33 +0,0 @@ -from fastapi.logger import logger -from pydantic_settings import BaseSettings - -from dotenv import load_dotenv - -from .models import ServerSettings -from functools import lru_cache - - -class Settings(BaseSettings): - """ Настройки приложения """ - ROOT_PATH: str = '/' - - server: ServerSettings = ServerSettings() - - class Config: - extra = 'ignore' - env_file = ".env" - env_nested_delimiter = '__' - nested_model_default_partial_update = False - - -@lru_cache -def get_settings() -> Settings: - """ - Получение настроек приложения - :return: Настройки приложения - """ - try: - _ = load_dotenv() - except Exception as err: - logger.error("Error load settings", exc_info=err) - return Settings() \ No newline at end of file diff --git a/api/src/core/__init__.py b/api/src/core/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/api/src/core/__init__.py @@ -0,0 +1 @@ + diff --git a/api/src/core/config.py b/api/src/core/config.py new file mode 100644 index 0000000..127b9eb --- /dev/null +++ b/api/src/core/config.py @@ -0,0 +1,116 @@ +import os +from typing import List + +from pydantic import AliasChoices, Field +from pydantic_settings import BaseSettings, SettingsConfigDict + +if os.environ.get("APP_ENV", "dev") == "test": + prefix = "OPENBAO__SETTINGS_TEST" +else: + prefix = "OPENBAO__SETTINGS" + + +class Settings(BaseSettings): + """Настройки backend для DFiP Budget Planing.""" + + ROOT_PATH: str = Field(default="", description="Префикс API", alias="ROOT_PATH") + + # Database + DATABASE_URL: str = Field( + default="sqlite+aiosqlite:///./dfip_budget_planing.db", + description="URL подключения к базе данных", + validation_alias=AliasChoices(f"{prefix}__DATABASE_URL", "DATABASE_URL"), + ) + DATABASE_SCHEMA: str = Field( + default="", + description="Схема БД (для Postgres search_path)", + validation_alias=AliasChoices(f"{prefix}__DATABASE_SCHEMA", "DATABASE_SCHEMA"), + ) + + # JWT + JWT_SECRET: str = Field( + default="change-me-in-production", + description="Секретный ключ для JWT", + ) + JWT_ALGORITHM: str = Field(default="HS256", description="Алгоритм JWT") + ACCESS_TOKEN_EXPIRE_MINUTES: int = Field( + default=60, + description="Время жизни access-токена (минуты)", + ) + REFRESH_TOKEN_EXPIRE_DAYS: int = Field( + default=7, + description="Время жизни refresh-токена (дни)", + ) + + # CORS + CORS_ORIGINS: List[str] = Field( + default=[ + "https://raisa.go.rshbank.ru", + "http://localhost:3000", + "http://localhost:8080", + "http://127.0.0.1:3000", + "http://127.0.0.1:8000", + ], + description="Разрешенные origins для CORS", + ) + + HOST: str = Field( + default="0.0.0.0", + description="Хост API", + validation_alias=AliasChoices("SERVER__HOST", "HOST"), + ) + PORT: int = Field( + default=8000, + description="Порт API", + validation_alias=AliasChoices("SERVER__PORT", "PORT"), + ) + + # Environment + ENVIRONMENT: str = Field( + default="development", + description="Окружение приложения", + alias="APP_ENV", + ) + DEBUG: bool = Field( + default=True, + description="Режим отладки", + validation_alias=AliasChoices(f"{prefix}__DEBUG", "DEBUG"), + ) + + # Authorization (prod branch) + JWKS_URL: str = Field( + default="https://keycloak.raisa.go.rshbank.ru/realms/datalab/protocol/openid-connect/certs", + description="URL для получения JWKS", + alias="JWKS_URL", + ) + APP_NAMESPACE: str = Field( + default="dfip", + description="Namespace приложения для protected-api", + alias="APP_NAMESPACE", + ) + APP_NAME: str = Field( + default="dfip_budget_planing_api", + description="Имя приложения для protected-api", + alias="APP_NAME", + ) + + # Audit log + AUDIT_LOG_RETENTION_DAYS: int = Field( + default=365, + description="Удалять записи аудита старше N дней", + alias="AUDIT_LOG_RETENTION_DAYS", + ) + AUDIT_LOG_CLEANUP_BATCH_SIZE: int = Field( + default=5000, + description="Размер батча для очистки аудита", + alias="AUDIT_LOG_CLEANUP_BATCH_SIZE", + ) + + model_config = SettingsConfigDict( + env_file=".env", + extra="ignore", + env_nested_delimiter="__", + ) + + +settings = Settings() diff --git a/api/src/core/errors.py b/api/src/core/errors.py new file mode 100644 index 0000000..b6dcf05 --- /dev/null +++ b/api/src/core/errors.py @@ -0,0 +1,31 @@ +from pydantic import BaseModel, Field + + +class ValidationError(BaseModel): + field: str = Field(...) + description: str = Field(...) + + +class BasicAppException(Exception): + description: str | None = None + + +class AccessDeniedException(BasicAppException): + description = "Доступ запрещен" + + +class ValidationException(BasicAppException): + description = "Ошибка валидации" + + def __init__(self, description: str | None = None, field: str | None = None, *args): + super().__init__(*args) + self.field = field + self.description = description or ValidationException.description + + +class UserNotFoundException(BasicAppException): + description = "Пользователь не найден" + + +class UsernameConflictException(BasicAppException): + description = "Пользователь с таким username уже существует" diff --git a/api/src/core/exception_handlers.py b/api/src/core/exception_handlers.py new file mode 100644 index 0000000..d7efc24 --- /dev/null +++ b/api/src/core/exception_handlers.py @@ -0,0 +1,47 @@ +from fastapi import FastAPI, status +from fastapi.responses import JSONResponse + +from src.core.errors import ( + AccessDeniedException, + BasicAppException, + UserNotFoundException, + UsernameConflictException, + ValidationException, +) + + +def register_exception_handlers(app: FastAPI) -> None: + @app.exception_handler(AccessDeniedException) + async def access_denied_exception_handler(request, exc: AccessDeniedException): + return JSONResponse( + status_code=status.HTTP_403_FORBIDDEN, + content={"detail": exc.description}, + ) + + @app.exception_handler(ValidationException) + async def validation_exception_handler(request, exc: ValidationException): + return JSONResponse( + status_code=status.HTTP_400_BAD_REQUEST, + content={"detail": exc.description}, + ) + + @app.exception_handler(UserNotFoundException) + async def user_not_found_exception_handler(request, exc: UserNotFoundException): + return JSONResponse( + status_code=status.HTTP_404_NOT_FOUND, + content={"detail": exc.description}, + ) + + @app.exception_handler(UsernameConflictException) + async def username_conflict_exception_handler(request, exc: UsernameConflictException): + return JSONResponse( + status_code=status.HTTP_409_CONFLICT, + content={"detail": exc.description}, + ) + + @app.exception_handler(BasicAppException) + async def basic_app_exception_handler(request, exc: BasicAppException): + return JSONResponse( + status_code=status.HTTP_400_BAD_REQUEST, + content={"detail": exc.description or "Application error"}, + ) diff --git a/api/src/core/security.py b/api/src/core/security.py new file mode 100644 index 0000000..2b394c3 --- /dev/null +++ b/api/src/core/security.py @@ -0,0 +1,55 @@ +from datetime import datetime, timedelta, timezone +from typing import Optional + +from argon2 import PasswordHasher +from argon2.exceptions import InvalidHashError, VerificationError, VerifyMismatchError +from jose import JWTError, jwt + +from src.core.config import settings + +pwd_context = PasswordHasher() + + +def verify_password(plain_password: str, hashed_password: Optional[str]) -> bool: + if not hashed_password: + return False + try: + return pwd_context.verify(hashed_password, plain_password) + except (VerifyMismatchError, VerificationError, InvalidHashError): + return False + + +def get_password_hash(password: str) -> str: + return pwd_context.hash(password) + + +def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str: + to_encode = data.copy() + if expires_delta: + expire = datetime.now(timezone.utc) + expires_delta + else: + expire = datetime.now(timezone.utc) + timedelta( + minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES + ) + to_encode.update({"exp": expire}) + return jwt.encode(to_encode, settings.JWT_SECRET, algorithm=settings.JWT_ALGORITHM) + + +def create_refresh_token(data: dict) -> str: + to_encode = data.copy() + expire = datetime.now(timezone.utc) + timedelta( + days=settings.REFRESH_TOKEN_EXPIRE_DAYS + ) + to_encode.update({"exp": expire}) + return jwt.encode(to_encode, settings.JWT_SECRET, algorithm=settings.JWT_ALGORITHM) + + +def verify_token(token: str) -> Optional[dict]: + try: + return jwt.decode( + token, + settings.JWT_SECRET, + algorithms=[settings.JWT_ALGORITHM], + ) + except JWTError: + return None diff --git a/api/src/db/__init__.py b/api/src/db/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/api/src/db/__init__.py @@ -0,0 +1 @@ + diff --git a/api/src/db/base.py b/api/src/db/base.py new file mode 100644 index 0000000..4458cb8 --- /dev/null +++ b/api/src/db/base.py @@ -0,0 +1,27 @@ +from sqlalchemy.ext.asyncio import AsyncAttrs, async_sessionmaker, create_async_engine +from sqlalchemy.orm import DeclarativeBase + +from src.core.config import settings + +connect_args = {} +if settings.DATABASE_SCHEMA: + connect_args["server_settings"] = {"search_path": settings.DATABASE_SCHEMA} + +if "sqlite" in settings.DATABASE_URL: + connect_args["check_same_thread"] = False + +engine = create_async_engine( + settings.DATABASE_URL, + pool_pre_ping=True, + connect_args=connect_args, +) + +SessionLocal = async_sessionmaker( + autoflush=False, + bind=engine, + expire_on_commit=False, +) + + +class Base(AsyncAttrs, DeclarativeBase): + pass diff --git a/api/src/db/session.py b/api/src/db/session.py new file mode 100644 index 0000000..515cb09 --- /dev/null +++ b/api/src/db/session.py @@ -0,0 +1,26 @@ +import asyncio +from typing import AsyncGenerator + +from alembic import command +from alembic.config import Config + +from src.core.config import settings +from src.db.base import SessionLocal + + +async def get_db() -> AsyncGenerator: + db = SessionLocal() + try: + yield db + finally: + await db.close() + + +async def run_migrations() -> None: + alembic_cfg = Config("alembic.ini") + await asyncio.to_thread(command.upgrade, alembic_cfg, "head") + + +async def create_tables() -> None: + if not settings.DEBUG: + await run_migrations() diff --git a/api/src/domain/__init__.py b/api/src/domain/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/api/src/domain/__init__.py @@ -0,0 +1 @@ + diff --git a/api/src/domain/enums.py b/api/src/domain/enums.py new file mode 100644 index 0000000..0e281c6 --- /dev/null +++ b/api/src/domain/enums.py @@ -0,0 +1,8 @@ +import enum + + +class AuditEventType(str, enum.Enum): + LOGIN = "LOGIN" + USER_CREATE = "USER_CREATE" + USER_UPDATE = "USER_UPDATE" + USER_DELETE = "USER_DELETE" diff --git a/api/src/domain/models.py b/api/src/domain/models.py new file mode 100644 index 0000000..5480f64 --- /dev/null +++ b/api/src/domain/models.py @@ -0,0 +1,70 @@ +import enum + +from sqlalchemy import (Boolean, Column, DateTime, ForeignKey, Integer, String, + UniqueConstraint) +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func + +from src.db.base import Base + + +class UserRole(int, enum.Enum): + ADMIN = 1 + EXECUTOR_DFIP = 2 + EXECUTOR_RF = 3 + + +class Roles(Base): + __tablename__ = "roles" + + id = Column(Integer, primary_key=True, index=True) + role = Column(String, nullable=False) + + user_role = relationship("Users", back_populates="role", lazy="select") + + +class Users(Base): + __tablename__ = "users" + + id = Column(Integer, primary_key=True, index=True) + email = Column(String, unique=True, index=True, nullable=False) + username = Column(String, unique=True, index=True, nullable=False) + hashed_password = Column(String, nullable=True) + full_name = Column(String, nullable=True) + role_id = Column(Integer, ForeignKey("roles.id"), nullable=False) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + is_active = Column(Boolean, default=True, nullable=False, server_default="true") + + role = relationship( + "Roles", + foreign_keys=[role_id], + back_populates="user_role", + lazy="selectin", + ) + user_ssp_link = relationship("UserSSPLink", back_populates="users", lazy="select") + + +class SSP(Base): + __tablename__ = "ssp" + + id = Column(Integer, primary_key=True, index=True) + title = Column(String, nullable=False) + is_active = Column(Boolean, default=True, nullable=False, server_default="true") + is_ssp = Column(Boolean, default=True, nullable=False, server_default="true") + + user_ssp_link = relationship("UserSSPLink", back_populates="ssp", lazy="select") + + +class UserSSPLink(Base): + __tablename__ = "user_ssp_link" + __table_args__ = ( + UniqueConstraint("user_id", "ssp_id", name="uq_user_ssp_link_user_id_ssp_id"), + ) + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + ssp_id = Column(Integer, ForeignKey("ssp.id"), nullable=False) + + users = relationship("Users", back_populates="user_ssp_link", lazy="select") + ssp = relationship("SSP", back_populates="user_ssp_link", lazy="select") diff --git a/api/src/domain/schemas.py b/api/src/domain/schemas.py new file mode 100644 index 0000000..8baedfc --- /dev/null +++ b/api/src/domain/schemas.py @@ -0,0 +1,99 @@ +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, ConfigDict, EmailStr, Field + + +class ResponseBase(BaseModel): + success: Optional[bool] = Field(None) + message: Optional[str] = Field(None) + + +class UserBase(BaseModel): + email: EmailStr + username: str = Field( + ..., + min_length=3, + max_length=64, + pattern=r"^[A-Za-z0-9_.-]+$", + ) + full_name: Optional[str] = Field(None, max_length=255, pattern=r"^[^<>]*$") + role_id: int = 3 + + +class UserCreate(UserBase): + password: str | None = None + + +class UserUpdate(BaseModel): + email: Optional[EmailStr] = None + username: Optional[str] = Field( + None, + min_length=3, + max_length=64, + pattern=r"^[A-Za-z0-9_.-]+$", + ) + full_name: Optional[str] = Field(None, max_length=255, pattern=r"^[^<>]*$") + role_id: Optional[int] = None + is_active: Optional[bool] = True + + +class UserInDB(UserBase): + model_config = ConfigDict(from_attributes=True) + + id: int + created_at: datetime + updated_at: Optional[datetime] = None + is_active: bool + + +class User(UserInDB): + pass + + +class UserResponse(ResponseBase): + result: Optional[UserInDB] = None + + +class UserListResponse(ResponseBase): + result: Optional[list[User]] = None + + +class SSPBase(BaseModel): + title: str + is_ssp: bool = True + is_active: bool = True + + +class SSPInDB(SSPBase): + model_config = ConfigDict(from_attributes=True) + id: int + + +class SSPIDList(BaseModel): + ssp_ids: list[int] + + +class UserSSPLinkBase(BaseModel): + user_id: int + ssp: list[SSPInDB] + + +class UserSSPLinkResponse(ResponseBase): + result: Optional[UserSSPLinkBase] = None + + +class Token(BaseModel): + access_token: str + token_type: str = "bearer" + expires_in: Optional[int] = None + refresh_token: Optional[str] = None + + +class LoginRequest(BaseModel): + username: str + password: str + + +class RefreshRequest(BaseModel): + refresh_token: str diff --git a/api/src/main.py b/api/src/main.py new file mode 100644 index 0000000..f98ba0b --- /dev/null +++ b/api/src/main.py @@ -0,0 +1,111 @@ +import os +from contextlib import asynccontextmanager +from datetime import datetime, timezone + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles +from starlette.middleware.gzip import GZipMiddleware +from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware + +from src.api.v1.router import api_router +from src.core.config import settings +from src.core.exception_handlers import register_exception_handlers +from src.db.base import engine +from src.db.session import create_tables + +if not settings.DEBUG: + from raisa_fastapi_protected_api import ( + AuthorizationMiddleware, + OpenEndpoint, + ProtectedOAuthSettings, + ProtectedRolesSettings, + ProtectedSettings, + SearchType, + ) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + if not settings.DEBUG: + await create_tables() + ProtectedSettings( + ProtectedOAuthSettings(JWKS_URI=settings.JWKS_URL), + ProtectedRolesSettings( + APP_NAMESPACE=settings.APP_NAMESPACE, + APP_NAME=settings.APP_NAME, + ), + ) + try: + yield + finally: + await engine.dispose() + + +app = FastAPI( + title="DFiP Budget Planning API", + version="1.0.0", + lifespan=lifespan, + root_path=settings.ROOT_PATH, +) +register_exception_handlers(app) + +VERSION = "1.0.0" +UP_TIME = datetime.now(timezone.utc) + + +@app.get("/healthcheck", status_code=200) +async def healthcheck(): + return {"uptime": UP_TIME, "version": VERSION} + + +@app.get("/healthcheck2", status_code=200) +async def healthcheck2(): + return { + "uptime": UP_TIME, + "version": VERSION, + "debug": settings.DEBUG, + "host": settings.HOST, + "port": settings.PORT, + } + + +if not settings.DEBUG: + app.add_middleware( + AuthorizationMiddleware, + open_endpoints=[ + OpenEndpoint(path="", type_search=SearchType.ABSOLUTE), + OpenEndpoint(path=settings.ROOT_PATH + "", type_search=SearchType.ABSOLUTE), + OpenEndpoint(path="/", type_search=SearchType.ABSOLUTE), + OpenEndpoint(path=settings.ROOT_PATH + "/", type_search=SearchType.ABSOLUTE), + OpenEndpoint(path="/healthcheck", type_search=SearchType.ABSOLUTE), + OpenEndpoint( + path=settings.ROOT_PATH + "/healthcheck", + type_search=SearchType.ABSOLUTE, + ), + OpenEndpoint(path="/openapi.json", type_search=SearchType.START), + OpenEndpoint( + path=settings.ROOT_PATH + "/openapi.json", + type_search=SearchType.START, + ), + OpenEndpoint(path="/docs", type_search=SearchType.START), + OpenEndpoint( + path=settings.ROOT_PATH + "/docs", + type_search=SearchType.START, + ), + ], + ) + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) +app.add_middleware(ProxyHeadersMiddleware, trusted_hosts=["*"]) +app.add_middleware(GZipMiddleware, minimum_size=1000, compresslevel=1) + +os.makedirs("web", exist_ok=True) +app.include_router(api_router, prefix="/api/v1") +app.mount("/", StaticFiles(directory="web", html=True), name="web") diff --git a/api/src/repository/__init__.py b/api/src/repository/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/api/src/repository/__init__.py @@ -0,0 +1 @@ + diff --git a/api/src/repository/user_repository.py b/api/src/repository/user_repository.py new file mode 100644 index 0000000..20d14fb --- /dev/null +++ b/api/src/repository/user_repository.py @@ -0,0 +1,137 @@ +from typing import Optional + +from sqlalchemy import delete, select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from src.core.security import get_password_hash, verify_password +from src.domain.models import Roles, SSP, UserSSPLink, Users + + +class UserRepository: + def __init__(self, db: AsyncSession): + self.db = db + + async def get(self, user_id: int) -> Optional[Users]: + return ( + (await self.db.execute(select(Users).where(Users.id == user_id))) + .scalars() + .first() + ) + + async def get_by_email(self, email: str) -> Optional[Users]: + return ( + (await self.db.execute(select(Users).where(Users.email == email))) + .scalars() + .first() + ) + + async def get_by_username(self, username: str) -> Optional[Users]: + return ( + (await self.db.execute(select(Users).where(Users.username == username))) + .scalars() + .first() + ) + + async def get_list(self, skip: int = 0, limit: int = 100) -> list[Users]: + query = ( + select(Users) + .where(Users.is_active.is_(True)) + .offset(skip) + .limit(limit) + .order_by(Users.id) + ) + return (await self.db.execute(query)).scalars().all() + + async def create(self, user_data: dict) -> Users: + hashed_password = ( + get_password_hash(user_data["password"]) if user_data.get("password") else None + ) + db_user = Users( + email=user_data["email"], + username=user_data["username"], + hashed_password=hashed_password, + full_name=user_data.get("full_name"), + role_id=user_data.get("role_id"), + ) + self.db.add(db_user) + await self.db.commit() + await self.db.refresh(db_user) + return db_user + + async def update(self, user_id: int, user_data: dict) -> Optional[Users]: + user = await self.get(user_id) + if not user: + return None + + for field, value in user_data.items(): + if field == "password" and value: + value = get_password_hash(value) + setattr(user, field, value) + + await self.db.commit() + await self.db.refresh(user) + return user + + async def logical_delete(self, user_id: int) -> bool: + query = update(Users).where(Users.id == user_id).values(is_active=False) + result = await self.db.execute(query) + await self.db.commit() + return result.rowcount > 0 + + async def authenticate(self, username: str, password: str) -> Optional[Users]: + user = await self.get_by_username(username) + if not user: + return None + if not verify_password(password, user.hashed_password): + return None + return user + + async def authenticate_via_email(self, email: str) -> Optional[Users]: + return await self.get_by_email(email) + + async def get_roles(self) -> list[Roles]: + return (await self.db.execute(select(Roles).order_by(Roles.id))).scalars().all() + + async def get_many_ssp(self, user_id: int) -> list[SSP]: + query = select(SSP).join(UserSSPLink, UserSSPLink.ssp_id == SSP.id).where( + UserSSPLink.user_id == user_id + ) + return (await self.db.execute(query)).scalars().all() + + async def get_many_ssp_ids(self, user_id: int) -> list[int]: + query = select(UserSSPLink.ssp_id).where(UserSSPLink.user_id == user_id) + return (await self.db.execute(query)).scalars().all() + + async def set_many_ssp(self, user_id: int, ssp_ids: list[int]) -> bool: + try: + for ssp_id in ssp_ids: + link = UserSSPLink(user_id=user_id, ssp_id=ssp_id) + self.db.add(link) + await self.db.commit() + return True + except Exception: + await self.db.rollback() + return False + + async def unset_many_ssp(self, user_id: int, ssp_ids: list[int]) -> bool: + try: + for ssp_id in ssp_ids: + query = delete(UserSSPLink).where( + (UserSSPLink.user_id == user_id) & (UserSSPLink.ssp_id == ssp_id) + ) + await self.db.execute(query) + await self.db.commit() + return True + except Exception: + await self.db.rollback() + return False + + async def clear_many_ssp(self, user_id: int) -> bool: + try: + query = delete(UserSSPLink).where(UserSSPLink.user_id == user_id) + await self.db.execute(query) + await self.db.commit() + return True + except Exception: + await self.db.rollback() + return False diff --git a/api/src/routes/__init__.py b/api/src/routes/__init__.py deleted file mode 100644 index a837474..0000000 --- a/api/src/routes/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from .api import api_route - -__all__ = [ - "api_route" -] \ No newline at end of file diff --git a/api/src/routes/api/__init__.py b/api/src/routes/api/__init__.py deleted file mode 100644 index 246b420..0000000 --- a/api/src/routes/api/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from .route import route as api_route - -__all__ = [ - "api_route" -] \ No newline at end of file diff --git a/api/src/routes/api/route.py b/api/src/routes/api/route.py deleted file mode 100644 index cb26bef..0000000 --- a/api/src/routes/api/route.py +++ /dev/null @@ -1,17 +0,0 @@ -from fastapi import APIRouter -from datetime import datetime, timezone - -from .schemas import ApiResponse - -route = APIRouter(prefix="/api",tags=['Base']) - - -@route.get( - "", - response_model=ApiResponse -) -async def get_data(): - return ApiResponse( - date_with_time=datetime.now(timezone.utc), - text="Fast API приложение" - ) \ No newline at end of file diff --git a/api/src/routes/api/schemas.py b/api/src/routes/api/schemas.py deleted file mode 100644 index 0a12428..0000000 --- a/api/src/routes/api/schemas.py +++ /dev/null @@ -1,7 +0,0 @@ -from pydantic import BaseModel -from datetime import datetime - - -class ApiResponse(BaseModel): - date_with_time: datetime - text: str \ No newline at end of file diff --git a/api/src/services/__init__.py b/api/src/services/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/api/src/services/__init__.py @@ -0,0 +1 @@ + diff --git a/api/src/services/auth_service.py b/api/src/services/auth_service.py new file mode 100644 index 0000000..e1c926d --- /dev/null +++ b/api/src/services/auth_service.py @@ -0,0 +1,58 @@ +from typing import Optional + +from sqlalchemy.ext.asyncio import AsyncSession + +from src.core.security import create_access_token, create_refresh_token, verify_token +from src.domain.schemas import Token +from src.repository.user_repository import UserRepository + + +class AuthService: + def __init__(self, db: AsyncSession): + self.db = db + self.user_repo = UserRepository(db) + + async def authenticate_user(self, username: str, password: str) -> Optional[Token]: + user = await self.user_repo.authenticate(username, password) + if not user: + return None + access_token = create_access_token(data={"sub": user.username}) + refresh_token = create_refresh_token(data={"sub": user.username}) + return Token( + access_token=access_token, + refresh_token=refresh_token, + token_type="bearer", + ) + + async def authenticate_user_via_email(self, email: str) -> Optional[Token]: + user = await self.user_repo.authenticate_via_email(email) + if not user: + return None + access_token = create_access_token(data={"sub": user.email}) + refresh_token = create_refresh_token(data={"sub": user.email}) + return Token( + access_token=access_token, + refresh_token=refresh_token, + token_type="bearer", + ) + + async def refresh_token(self, refresh_token: str) -> Optional[Token]: + payload = verify_token(refresh_token) + if payload is None: + return None + + username: str | None = payload.get("sub") + if username is None: + return None + + user = await self.user_repo.get_by_username(username) + if not user: + return None + + access_token = create_access_token(data={"sub": user.username}) + new_refresh_token = create_refresh_token(data={"sub": user.username}) + return Token( + access_token=access_token, + refresh_token=new_refresh_token, + token_type="bearer", + ) diff --git a/api/src/services/user_service.py b/api/src/services/user_service.py new file mode 100644 index 0000000..ae08a72 --- /dev/null +++ b/api/src/services/user_service.py @@ -0,0 +1,167 @@ +from sqlalchemy.ext.asyncio import AsyncSession + +from src.core.errors import ( + AccessDeniedException, + UserNotFoundException, + ValidationException, + UsernameConflictException, +) +from src.domain.models import UserRole, Users +from src.domain.schemas import UserCreate, UserUpdate +from src.repository.user_repository import UserRepository + + +class UserService: + def __init__(self, db: AsyncSession): + self.db = db + self.user_repo = UserRepository(db) + + async def get_all(self, current_user: Users, skip: int = 0, limit: int = 100): + if current_user.role_id != UserRole.ADMIN: + raise AccessDeniedException() + return await self.user_repo.get_list(skip=skip, limit=limit) + + async def get(self, user_id: int, current_user: Users): + if current_user.role_id != UserRole.ADMIN: + raise AccessDeniedException() + return await self.user_repo.get(user_id) + + async def create_user(self, user_data: UserCreate, creator: Users): + if creator.role_id != UserRole.ADMIN: + raise AccessDeniedException() + + role_id = user_data.role_id or UserRole.EXECUTOR_RF.value + try: + role = UserRole(role_id) + except ValueError: + raise ValidationException("Роль не найдена", field="role_id") + user_data.role_id = role.value + + if await self.user_repo.get_by_username(user_data.username): + raise UsernameConflictException() + + payload = user_data.model_dump(mode="json") + return await self.user_repo.create(payload) + + async def update_user(self, user_id: int, user_data: UserUpdate, user: Users): + if user.role_id != UserRole.ADMIN: + raise AccessDeniedException() + + target = await self.user_repo.get(user_id) + if not target: + raise UserNotFoundException() + + update_payload = user_data.model_dump(mode="json", exclude_unset=True) + previous_role_id = self._normalize_role_id(target.role_id) + role_id = update_payload.get("role_id") + role = None + if role_id is not None: + try: + role = UserRole(role_id) + except ValueError: + raise ValidationException("Роль не найдена", field="role_id") + + if role_id != self._normalize_role_id(user.role_id) and user.id == user_id: + raise ValidationException( + "Нельзя изменить роль самому себе", field="role_id" + ) + update_payload["role_id"] = role.value + + if "username" in update_payload and update_payload["username"] != target.username: + conflict = await self.user_repo.get_by_username(update_payload["username"]) + if conflict: + raise UsernameConflictException() + + updated = await self.user_repo.update(user_id, update_payload) + + if role_id is not None and role is not None: + had_many_ssp_role = previous_role_id in ( + UserRole.ADMIN.value, + UserRole.EXECUTOR_DFIP.value, + ) + has_many_ssp_role_now = role.value in ( + UserRole.ADMIN.value, + UserRole.EXECUTOR_DFIP.value, + ) + is_executor_to_dfip_transition = ( + previous_role_id == UserRole.EXECUTOR_RF.value + and role.value == UserRole.EXECUTOR_DFIP.value + ) + if (had_many_ssp_role and not has_many_ssp_role_now) or is_executor_to_dfip_transition: + await self.user_repo.clear_many_ssp(user_id) + + return updated + + async def delete_user(self, user_id: int, user: Users): + if user.role_id != UserRole.ADMIN: + raise AccessDeniedException() + deleted = await self.user_repo.logical_delete(user_id) + if not deleted: + raise UserNotFoundException() + + async def get_roles(self, current_user: Users): + if current_user.role_id != UserRole.ADMIN: + raise AccessDeniedException() + return await self.user_repo.get_roles() + + async def get_many_ssp(self, user_id: int, current_user: Users): + if current_user.role_id == UserRole.ADMIN: + return await self.user_repo.get_many_ssp(user_id) + if current_user.id != user_id: + raise AccessDeniedException() + return await self.user_repo.get_many_ssp(user_id) + + async def set_many_ssp(self, user_id: int, ssp_ids: list[int], current_user: Users): + if current_user.role_id != UserRole.ADMIN: + raise AccessDeniedException() + + user = await self.user_repo.get(user_id) + if not user: + raise UserNotFoundException() + + target_role_id = self._normalize_role_id(user.role_id) + if target_role_id not in ( + UserRole.ADMIN.value, + UserRole.EXECUTOR_DFIP.value, + UserRole.EXECUTOR_RF.value, + ): + err = AccessDeniedException() + err.description = ( + "Несколько ССП/РФ может быть только у пользовтелей с ролями EXECUTOR и ADMIN" + ) + raise err + + curr_ssp_ids = await self.user_repo.get_many_ssp_ids(user_id) + if target_role_id == UserRole.EXECUTOR_RF.value: + merged_ssp_ids = set(curr_ssp_ids) | set(ssp_ids) + if len(merged_ssp_ids) > 1: + raise ValidationException( + "Для пользователя с ролью EXECUTOR_RF можно назначить только один ССП/РФ" + ) + new_ssp_ids = [ssp_id for ssp_id in ssp_ids if ssp_id not in curr_ssp_ids] + return await self.user_repo.set_many_ssp(user.id, new_ssp_ids) + + async def unset_many_ssp(self, user_id: int, ssp_ids: list[int], current_user: Users): + if current_user.role_id != UserRole.ADMIN: + raise AccessDeniedException() + + user = await self.user_repo.get(user_id) + if not user: + raise UserNotFoundException() + + target_role_id = self._normalize_role_id(user.role_id) + if target_role_id not in ( + UserRole.ADMIN.value, + UserRole.EXECUTOR_DFIP.value, + UserRole.EXECUTOR_RF.value, + ): + err = AccessDeniedException() + err.description = ( + "Несколько ССП/РФ может быть только у пользовтелей с ролями EXECUTOR и ADMIN" + ) + raise err + return await self.user_repo.unset_many_ssp(user_id, ssp_ids) + + @staticmethod + def _normalize_role_id(role_id: int | UserRole) -> int: + return role_id.value if isinstance(role_id, UserRole) else int(role_id) diff --git a/api/tests/integration/conftest.py b/api/tests/integration/conftest.py new file mode 100644 index 0000000..ed451f5 --- /dev/null +++ b/api/tests/integration/conftest.py @@ -0,0 +1,49 @@ +import os +from pathlib import Path + +import pytest +from alembic import command +from alembic.config import Config +from fastapi.testclient import TestClient + +# Keep a single sqlite target for app + alembic during tests. +TEST_DATABASE_URL = "sqlite+aiosqlite:///./app.db" +os.environ["DATABASE_URL"] = TEST_DATABASE_URL +os.environ["OPENBAO__SETTINGS__DATABASE_URL"] = TEST_DATABASE_URL +os.environ["OPENBAO__SETTINGS_TEST__DATABASE_URL"] = TEST_DATABASE_URL +os.environ["OPENBAO__SETTINGS__DEBUG"] = "true" +os.environ["OPENBAO__SETTINGS_TEST__DEBUG"] = "true" + +from src.main import app + + +@pytest.fixture(scope="session", autouse=True) +def migrate_db(): + db_files = [Path("app.db"), Path("dfip_budget_planing.db"), Path("dfip_new_schema.db")] + for db_file in db_files: + if db_file.exists(): + os.remove(db_file) + + alembic_cfg = Config("alembic.ini") + command.upgrade(alembic_cfg, "head") + yield + + for db_file in db_files: + if db_file.exists(): + os.remove(db_file) + + +@pytest.fixture +def client(): + with TestClient(app) as test_client: + yield test_client + + +@pytest.fixture +def admin_tokens(client): + response = client.post( + "/api/v1/auth/login", + json={"username": "admin", "password": "admin"}, + ) + assert response.status_code == 200 + return response.json() diff --git a/api/tests/integration/test_auth_api_smoke.py b/api/tests/integration/test_auth_api_smoke.py new file mode 100644 index 0000000..7a6aee6 --- /dev/null +++ b/api/tests/integration/test_auth_api_smoke.py @@ -0,0 +1,27 @@ +def test_login_success(client): + response = client.post( + "/api/v1/auth/login", + json={"username": "admin", "password": "admin"}, + ) + assert response.status_code == 200 + data = response.json() + assert "access_token" in data + assert "refresh_token" in data + + +def test_login_invalid_password(client): + response = client.post( + "/api/v1/auth/login", + json={"username": "admin", "password": "wrong"}, + ) + assert response.status_code == 401 + + +def test_refresh_success(client, admin_tokens): + response = client.post( + "/api/v1/auth/refresh", + json={"refresh_token": admin_tokens["refresh_token"]}, + ) + assert response.status_code == 200 + data = response.json() + assert "access_token" in data diff --git a/api/tests/integration/test_users_api_smoke.py b/api/tests/integration/test_users_api_smoke.py new file mode 100644 index 0000000..bd535dc --- /dev/null +++ b/api/tests/integration/test_users_api_smoke.py @@ -0,0 +1,30 @@ +def _auth_headers(tokens: dict) -> dict: + return {"Authorization": f"Bearer {tokens['access_token']}"} + + +def test_users_me(client, admin_tokens): + response = client.get("/api/v1/users/me", headers=_auth_headers(admin_tokens)) + assert response.status_code == 200 + assert response.json()["username"] == "admin" + + +def test_users_create_and_list(client, admin_tokens): + create_payload = { + "email": "user1@example.com", + "username": "user1", + "password": "pass123", + "full_name": "User One", + "role_id": 2, + } + created = client.put( + "/api/v1/users/", + json=create_payload, + headers=_auth_headers(admin_tokens), + ) + assert created.status_code == 200 + + listed = client.get("/api/v1/users/", headers=_auth_headers(admin_tokens)) + assert listed.status_code == 200 + usernames = [row["username"] for row in listed.json()["result"]] + assert "admin" in usernames + assert "user1" in usernames diff --git a/api/tests/unit/test_auth_service.py b/api/tests/unit/test_auth_service.py new file mode 100644 index 0000000..23d307b --- /dev/null +++ b/api/tests/unit/test_auth_service.py @@ -0,0 +1,32 @@ +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from src.services.auth_service import AuthService + + +@pytest.mark.asyncio +async def test_authenticate_user_returns_token(monkeypatch): + db = AsyncMock() + service = AuthService(db) + service.user_repo = MagicMock() + service.user_repo.authenticate = AsyncMock( + return_value=MagicMock(username="admin", email="admin@example.com") + ) + + token = await service.authenticate_user("admin", "admin") + + assert token is not None + assert token.access_token + assert token.refresh_token + assert token.token_type == "bearer" + + +@pytest.mark.asyncio +async def test_refresh_token_returns_none_for_invalid(): + db = AsyncMock() + service = AuthService(db) + + token = await service.refresh_token("bad-token") + + assert token is None diff --git a/api/tests/unit/test_user_service_role_ssp_logic.py b/api/tests/unit/test_user_service_role_ssp_logic.py new file mode 100644 index 0000000..0b8e5ae --- /dev/null +++ b/api/tests/unit/test_user_service_role_ssp_logic.py @@ -0,0 +1,64 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from src.core.errors import ValidationException +from src.domain.schemas import UserUpdate +from src.services.user_service import UserService + + +@pytest.mark.asyncio +async def test_update_user_clears_ssp_links_on_dfip_to_rf_role_change(): + db = AsyncMock() + service = UserService(db) + service.user_repo = MagicMock() + service.user_repo.get = AsyncMock( + return_value=SimpleNamespace( + id=10, + username="target", + role_id=2, + ) + ) + service.user_repo.get_by_username = AsyncMock(return_value=None) + service.user_repo.update = AsyncMock( + return_value=SimpleNamespace(id=10, role_id=3, username="target") + ) + service.user_repo.clear_many_ssp = AsyncMock(return_value=True) + + admin = SimpleNamespace(id=1, role_id=1) + await service.update_user(10, UserUpdate(role_id=3), admin) + + service.user_repo.clear_many_ssp.assert_awaited_once_with(10) + + +@pytest.mark.asyncio +async def test_update_user_prevents_self_role_change(): + db = AsyncMock() + service = UserService(db) + service.user_repo = MagicMock() + service.user_repo.get = AsyncMock( + return_value=SimpleNamespace( + id=1, + username="admin", + role_id=1, + ) + ) + service.user_repo.get_by_username = AsyncMock(return_value=None) + + admin = SimpleNamespace(id=1, role_id=1) + with pytest.raises(ValidationException): + await service.update_user(1, UserUpdate(role_id=2), admin) + + +@pytest.mark.asyncio +async def test_set_many_ssp_blocks_multiple_links_for_rf(): + db = AsyncMock() + service = UserService(db) + service.user_repo = MagicMock() + service.user_repo.get = AsyncMock(return_value=SimpleNamespace(id=7, role_id=3)) + service.user_repo.get_many_ssp_ids = AsyncMock(return_value=[1]) + + admin = SimpleNamespace(id=1, role_id=1) + with pytest.raises(ValidationException): + await service.set_many_ssp(7, [2], admin)