diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml index efc26c9..70468ad 100644 --- a/.github/workflows/backend.yml +++ b/.github/workflows/backend.yml @@ -48,6 +48,13 @@ jobs: working-directory: ./backend run: poetry install + - name: Prepare .env.test for CI + working-directory: ./backend + run: | + echo "ENV=test" > .env.test + echo "DATABASE_URL=${{ env.DATABASE_URL }}" >> .env.test + echo "SECRET_KEY=ci_secret_key" >> .env.test + - name: Run isort working-directory: ./backend run: poetry run isort . --check-only diff --git a/.gitignore b/.gitignore index e39d45c..e768b80 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,6 @@ # Environment files .env -.env.test # Editor configs .idea/ diff --git a/backend/.env.test b/backend/.env.test new file mode 100644 index 0000000..ad777c2 --- /dev/null +++ b/backend/.env.test @@ -0,0 +1,7 @@ +ENV=test +DATABASE_URL=postgresql+psycopg2://evsy_test:evsy_test@localhost:5433/evsy_test +SECRET_KEY=YOUR_32_CHAR_SECRET_KEY +GITHUB_CLIENT_ID= +GITHUB_CLIENT_SECRET= +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= \ No newline at end of file diff --git a/backend/Makefile b/backend/Makefile index c359d91..86775b1 100644 --- a/backend/Makefile +++ b/backend/Makefile @@ -26,16 +26,6 @@ test: @echo "Waiting for database to be ready..." @until docker exec evsy-db-test pg_isready -U evsy_test > /dev/null 2>&1; do sleep 1; done @echo "Database is ready. Running tests..." - -@ENV=test poetry run pytest --cov=app --cov-report=term --cov-report=html tests - @echo "Cleaning up..." - @docker rm -f evsy-db-test > /dev/null - -test-fast: - @echo "Starting test database container..." - @docker run --name evsy-db-test -e POSTGRES_USER=evsy_test -e POSTGRES_PASSWORD=evsy_test -e POSTGRES_DB=evsy_test -p 5433:5432 -d postgres:15-alpine > /dev/null - @echo "Waiting for database to be ready..." - @until docker exec evsy-db-test pg_isready -U evsy_test > /dev/null 2>&1; do sleep 1; done - @echo "Database is ready. Running tests..." - -@ENV=test poetry run pytest tests + @poetry run pytest --cov=app --cov-report=term --cov-report=html tests @echo "Cleaning up..." @docker rm -f evsy-db-test > /dev/null \ No newline at end of file diff --git a/backend/app/api/v1/routes/generic.py b/backend/app/api/v1/routes/generic.py index ebe9488..9338616 100644 --- a/backend/app/api/v1/routes/generic.py +++ b/backend/app/api/v1/routes/generic.py @@ -26,7 +26,7 @@ def ping(settings: Settings = Depends(get_settings)): ) def get_config(settings: Settings = Depends(get_settings)): return { - "database_url": settings.database_url, + "database_url": settings.masked_database_url, "debug": settings.is_dev, } diff --git a/backend/app/core/database.py b/backend/app/core/database.py index 6885b10..05d3c8b 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -6,7 +6,6 @@ Base = declarative_base() -# Globals (will be set by init_db) _engine: Engine | None = None _SessionLocal: sessionmaker | None = None @@ -15,12 +14,7 @@ def init_db(settings: Settings): """Initialize SQLAlchemy engine and sessionmaker with provided settings.""" global _engine, _SessionLocal - _engine = create_engine( - settings.database_url, - connect_args=( - {"check_same_thread": False} if "sqlite" in settings.database_url else {} - ), - ) + _engine = create_engine(settings.database_url) _SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=_engine) return _engine, _SessionLocal diff --git a/backend/app/factory.py b/backend/app/factory.py index fa9d9c1..58173ba 100644 --- a/backend/app/factory.py +++ b/backend/app/factory.py @@ -1,8 +1,10 @@ +import logging from contextlib import asynccontextmanager from fastapi import FastAPI from fastapi.exceptions import RequestValidationError from fastapi.middleware.cors import CORSMiddleware +from sqlalchemy.engine import Engine from sqlalchemy.orm import sessionmaker from starlette.exceptions import HTTPException as StarletteHTTPException @@ -12,17 +14,25 @@ from app.modules.auth.service import create_user_if_not_exists from app.settings import Settings +logger = logging.getLogger(__name__) -def create_app(settings: Settings, SessionLocal: sessionmaker) -> FastAPI: + +def create_app( + settings: Settings, engine: Engine, SessionLocal: sessionmaker +) -> FastAPI: @asynccontextmanager async def lifespan(app: FastAPI): + logger.info(f"Starting application in {settings.env} mode") if settings.is_demo: with SessionLocal() as db: create_user_if_not_exists( db, UserCreate(email="demo@evsy.dev", password="bestructured") ) yield + logger.info("Shutting down application") + engine.dispose() + logger.info("Database connections closed") app = FastAPI( title="Evsy API", @@ -53,21 +63,9 @@ async def lifespan(app: FastAPI): app.state.settings = settings - if settings.is_dev: - print("Running in development mode") - elif settings.is_demo: - print("Running in demo mode") - - allowed_origins = ["http://localhost:5173", "http://localhost:3000"] - allowed_origins = ( - allowed_origins + [settings.frontend_url] - if settings.is_dev - else [settings.frontend_url] - ) - app.add_middleware( CORSMiddleware, - allow_origins=allowed_origins, + allow_origins=settings.cors_origins, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], diff --git a/backend/app/main.py b/backend/app/main.py index 2469c92..52b67c6 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,16 +1,24 @@ +import logging import sys from pydantic import ValidationError from app.core.database import init_db from app.factory import create_app -from app.settings import Settings +from app.settings import get_settings + +settings = get_settings() + +logging.basicConfig( + level=settings.log_level, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + stream=sys.stdout, +) try: - settings = Settings() + engine, SessionLocal = init_db(settings) except ValidationError as e: print("❌ Invalid ENV value in configuration:", e) sys.exit(1) -engine, SessionLocal = init_db(settings) -app = create_app(settings, SessionLocal) +app = create_app(settings, engine, SessionLocal) diff --git a/backend/app/modules/auth/oauth.py b/backend/app/modules/auth/oauth.py index 44c29c7..db84e4c 100644 --- a/backend/app/modules/auth/oauth.py +++ b/backend/app/modules/auth/oauth.py @@ -4,9 +4,7 @@ from fastapi import HTTPException from app.modules.auth.schemas import OAuthLogin -from app.settings import Settings - -settings = Settings() +from app.settings import get_settings # --- Provider-specific logic --- @@ -52,36 +50,38 @@ def get_email_from_google(token: str) -> str: # --- Provider config registry --- -OAUTH_PROVIDERS = { - "github": { - "client_id": settings.github_client_id, - "client_secret": settings.github_client_secret, - "auth_url": "https://github.com/login/oauth/authorize", - "token_url": "https://github.com/login/oauth/access_token", - "scope": "user:email", - "email_fetcher": get_email_from_github, - "headers": {"Accept": "application/json"}, - }, - "google": { - "client_id": settings.google_client_id, - "client_secret": settings.google_client_secret, - "auth_url": "https://accounts.google.com/o/oauth2/v2/auth", - "token_url": "https://oauth2.googleapis.com/token", - "scope": "openid email profile", - "email_fetcher": get_email_from_google, - "headers": {"Content-Type": "application/x-www-form-urlencoded"}, - "extra_auth_params": {"access_type": "offline", "prompt": "consent"}, - }, -} - -# --- Generic Logic --- + +def get_oauth_config() -> dict: + settings = get_settings() + return { + "github": { + "client_id": settings.github_client_id, + "client_secret": settings.github_client_secret, + "auth_url": "https://github.com/login/oauth/authorize", + "token_url": "https://github.com/login/oauth/access_token", + "scope": "user:email", + "email_fetcher": get_email_from_github, + "headers": {"Accept": "application/json"}, + }, + "google": { + "client_id": settings.google_client_id, + "client_secret": settings.google_client_secret, + "auth_url": "https://accounts.google.com/o/oauth2/v2/auth", + "token_url": "https://oauth2.googleapis.com/token", + "scope": "openid email profile", + "email_fetcher": get_email_from_google, + "headers": {"Content-Type": "application/x-www-form-urlencoded"}, + "extra_auth_params": {"access_type": "offline", "prompt": "consent"}, + }, + } def build_oauth_redirect(provider: str, redirect_uri: str, state: str) -> str: - if provider not in OAUTH_PROVIDERS: + providers = get_oauth_config() + if provider not in providers: raise HTTPException(status_code=400, detail="Unsupported provider") - cfg = OAUTH_PROVIDERS[provider] + cfg = providers[provider] query = { "client_id": cfg["client_id"], "redirect_uri": redirect_uri, @@ -101,10 +101,11 @@ def _post_token_request(url: str, data: dict, headers: dict) -> dict: def exchange_code_for_email(provider: str, code: str, redirect_uri: str) -> str: - if provider not in OAUTH_PROVIDERS: + providers = get_oauth_config() + if provider not in providers: raise HTTPException(status_code=400, detail="Unsupported provider") - cfg = OAUTH_PROVIDERS[provider] + cfg = providers[provider] try: data = { "code": code, @@ -129,5 +130,6 @@ def exchange_code_for_email(provider: str, code: str, redirect_uri: str) -> str: def get_email_from_oauth(login: OAuthLogin) -> str: + settings = get_settings() redirect_uri = f"{settings.backend_url}/api/v1/auth/oauth/callback" return exchange_code_for_email(login.provider, login.token, redirect_uri) diff --git a/backend/app/modules/auth/token.py b/backend/app/modules/auth/token.py index ce4a26c..3377e64 100644 --- a/backend/app/modules/auth/token.py +++ b/backend/app/modules/auth/token.py @@ -9,30 +9,25 @@ from app.core.database import get_db from app.modules.auth import crud from app.modules.auth.models import User -from app.settings import Settings +from app.settings import Settings, get_settings -settings = Settings() - -# Secret and settings -SECRET_KEY = settings.secret_key -ALGORITHM = "HS256" -ACCESS_TOKEN_EXPIRE_MINUTES = 60 - -# OAuth2 scheme for token dependency oauth2_scheme = OAuth2PasswordBearer(tokenUrl="api/v1/auth/token") def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str: + settings = get_settings() to_encode = data.copy() expire = datetime.now(UTC) + ( - expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + expires_delta or timedelta(minutes=settings.access_token_expire_minutes) ) to_encode.update({"exp": expire}) - return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) + return jwt.encode(to_encode, settings.secret_key, algorithm=settings.jwt_algorithm) def get_current_user( - token: str = Depends(oauth2_scheme), db: Session = Depends(get_db) + token: str = Depends(oauth2_scheme), + db: Session = Depends(get_db), + settings: Settings = Depends(get_settings), ) -> User: credentials_exception = HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -40,7 +35,9 @@ def get_current_user( headers={"WWW-Authenticate": "Bearer"}, ) try: - payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + payload = jwt.decode( + token, settings.secret_key, algorithms=[settings.jwt_algorithm] + ) email: str = payload.get("sub") if email is None: raise credentials_exception diff --git a/backend/app/settings.py b/backend/app/settings.py index 6c02062..5cd5244 100644 --- a/backend/app/settings.py +++ b/backend/app/settings.py @@ -1,72 +1,77 @@ import os from functools import lru_cache from pathlib import Path -from typing import Literal, Optional +from typing import Any, Literal, Optional from dotenv import load_dotenv -from pydantic import Field +from pydantic import Field, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict def resolve_env_file() -> Optional[str]: - # 1. Check if ENV is set (e.g., ENV=test) env_mode = os.getenv("ENV") - - # Priority: current working directory (for tests) cwd = Path.cwd() - if env_mode and (cwd / f".env.{env_mode}").exists(): - return str(cwd / f".env.{env_mode}") - if (cwd / ".env").exists(): - return str(cwd / ".env") - # Fallback: Absolute paths relative to this file - backend_root = Path(__file__).parent.parent + candidates = [] if env_mode: - test_path = backend_root / f".env.{env_mode}" - if test_path.exists(): - return str(test_path) - - local_env = backend_root / ".env" - if local_env.exists(): - return str(local_env) + candidates.append(cwd / f".env.{env_mode}") + candidates.append(cwd / ".env") - root_env = backend_root.parent / ".env" - if root_env.exists(): - return str(root_env) + backend_root = Path(__file__).parent.parent + if env_mode: + candidates.append(backend_root / f".env.{env_mode}") + candidates.append(backend_root / ".env") + for path in candidates: + if path.exists(): + return str(path) return None class Settings(BaseSettings): - def __init__(self, _env_file: Optional[str] = None, **kwargs): - # Dynamically resolve env file if not provided + def __init__(self, _env_file: Optional[str] = None, **kwargs: Any): if _env_file is None: _env_file = resolve_env_file() - if _env_file: - load_dotenv(_env_file, override=True) - + load_dotenv(dotenv_path=_env_file, override=True) super().__init__(**kwargs) env: Literal["dev", "prod", "demo", "test"] = Field(default="dev", alias="ENV") - database_url: str = "sqlite:///./test.db" - frontend_url: Optional[str] = None - backend_url: str = "http://localhost:8000" - - secret_key: str = "your_secret_key_here" + database_url: str = Field(default="", alias="DATABASE_URL") + frontend_url: str = Field(default="http://localhost:3000", alias="FRONTEND_URL") + backend_url: str = Field(default="http://localhost:8000", alias="BACKEND_URL") + + secret_key: str = Field(default="your_secret_key_here", alias="SECRET_KEY") + jwt_algorithm: str = Field(default="HS256", alias="JWT_ALGORITHM") + access_token_expire_minutes: int = Field( + default=60, alias="ACCESS_TOKEN_EXPIRE_MINUTES" + ) - github_client_id: Optional[str] = None - github_client_secret: Optional[str] = None + github_client_id: Optional[str] = Field(default=None, alias="GITHUB_CLIENT_ID") + github_client_secret: Optional[str] = Field( + default=None, alias="GITHUB_CLIENT_SECRET" + ) - google_client_id: Optional[str] = None - google_client_secret: Optional[str] = None + google_client_id: Optional[str] = Field(default=None, alias="GOOGLE_CLIENT_ID") + google_client_secret: Optional[str] = Field( + default=None, alias="GOOGLE_CLIENT_SECRET" + ) model_config = SettingsConfigDict( env_file_encoding="utf-8", case_sensitive=False, extra="ignore", + populate_by_name=True, ) + @model_validator(mode="after") + def validate_infrastructure(self) -> "Settings": + if not self.database_url: + raise ValueError( + "DATABASE_URL must be provided in the environment or a .env file" + ) + return self + @property def is_dev(self): return self.env == "dev" @@ -79,6 +84,34 @@ def is_prod(self): def is_demo(self): return self.env == "demo" + @property + def log_level(self) -> str: + if self.is_dev or self.env == "test": + return "DEBUG" + return "INFO" + + @property + def cors_origins(self) -> list[str]: + origins = [self.frontend_url] + if self.is_dev: + origins.extend(["http://localhost:5173", "http://localhost:3000"]) + return list(set(filter(None, origins))) + + @property + def masked_database_url(self) -> str: + from urllib.parse import urlparse, urlunparse + + if not self.database_url: + return "" + parsed = urlparse(self.database_url) + if not parsed.password: + return self.database_url + host_port = parsed.hostname or "" + if parsed.port: + host_port = f"{host_port}:{parsed.port}" + user_part = f"{parsed.username}:******@" if parsed.username else "******@" + return urlunparse(parsed._replace(netloc=f"{user_part}{host_port}")) + @property def available_oauth_providers(self) -> list[str]: providers = [] diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 7f32dec..299232e 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -10,14 +10,10 @@ from app.modules.auth.token import create_access_token from app.settings import Settings -# No need for manual load_dotenv, Settings() will handle it via resolve_env_file() -# or we can pass it explicitly for maximum clarity in tests. - @pytest.fixture(scope="session") def test_settings(): """Session-scoped fixture for test settings, using a Postgres DB.""" - # We explicitly pass the env file to ensure we use exactly what we want return Settings(_env_file=".env.test") @@ -35,7 +31,7 @@ def app(test_settings: Settings, db_engine): """Session-scoped fixture for the FastAPI application instance.""" # We use a dummy session_local here because we'll override get_db at the request level dummy_session_local = sessionmaker(bind=db_engine) - return create_app(test_settings, dummy_session_local) + return create_app(test_settings, db_engine, dummy_session_local) @pytest.fixture diff --git a/backend/tests/test_fields.py b/backend/tests/test_fields.py index 591d818..2160d0f 100644 --- a/backend/tests/test_fields.py +++ b/backend/tests/test_fields.py @@ -5,22 +5,18 @@ @pytest.fixture def sample_field(): - """Тестовое событие""" return FieldCreate( name="from_page", description="Some field description.", field_type="string" ) def test_create_field(auth_client, sample_field): - """Тест на создание события""" response = auth_client.post("/v1/fields/", json=sample_field.model_dump()) assert response.status_code == 201 assert response.json()["name"] == "from_page" def test_get_field(auth_client, sample_field): - """Тест на получение события""" - # Create a field first create_response = auth_client.post("/v1/fields/", json=sample_field.model_dump()) field_id = create_response.json()["id"] diff --git a/backend/tests/test_settings.py b/backend/tests/test_settings.py index 9c95370..0017dad 100644 --- a/backend/tests/test_settings.py +++ b/backend/tests/test_settings.py @@ -13,33 +13,55 @@ def write_temp_env(dir_path: Path, filename: str, content: str) -> Path: def test_dev_env_flags(tmp_path: Path, monkeypatch): monkeypatch.setenv("ENV", "dev") - write_temp_env(tmp_path, ".env.dev", "ENV=dev") + url = "postgresql://user:pass@localhost/db" + monkeypatch.setenv("DATABASE_URL", url) + env_file = write_temp_env(tmp_path, ".env.dev", f"ENV=dev\nDATABASE_URL={url}") - monkeypatch.chdir(tmp_path) - - settings = Settings() + settings = Settings(_env_file=str(env_file)) assert settings.env == "dev" assert settings.is_dev is True assert settings.is_prod is False assert settings.is_demo is False + assert settings.log_level == "DEBUG" + assert "http://localhost:3000" in settings.cors_origins + + +def test_settings_masked_db_url(): + """Test that database password is masked in masked_database_url.""" + url = "postgresql+psycopg2://user:secret_pass@localhost:5432/db" + settings = Settings.model_construct(database_url=url) + + masked = settings.masked_database_url + assert "secret_pass" not in masked + assert "******" in masked + assert "user:******@localhost:5432/db" in masked + + +def test_settings_masked_db_url_no_pass(): + """Test masked_database_url when no password is present.""" + url = "postgresql://localhost/db" + settings = Settings.model_construct(database_url=url) + assert settings.masked_database_url == url def test_invalid_env_rejected(tmp_path: Path, monkeypatch): monkeypatch.setenv("ENV", "staging") - write_temp_env(tmp_path, ".env.staging", "ENV=staging") - - monkeypatch.chdir(tmp_path) + url = "postgresql://localhost/db" + monkeypatch.setenv("DATABASE_URL", url) + env_file = write_temp_env( + tmp_path, ".env.staging", f"ENV=staging\nDATABASE_URL={url}" + ) with pytest.raises(ValueError): - Settings() + Settings(_env_file=str(env_file)) def test_fallback_to_dotenv(tmp_path: Path, monkeypatch): monkeypatch.delenv("ENV", raising=False) - write_temp_env(tmp_path, ".env", "ENV=demo") - - monkeypatch.chdir(tmp_path) + url = "postgresql://localhost/db" + monkeypatch.setenv("DATABASE_URL", url) + env_file = write_temp_env(tmp_path, ".env", f"ENV=demo\nDATABASE_URL={url}") - settings = Settings() + settings = Settings(_env_file=str(env_file)) assert settings.env == "demo" assert settings.is_demo is True diff --git a/backend/tests/test_tags.py b/backend/tests/test_tags.py index 0a41f79..6864aad 100644 --- a/backend/tests/test_tags.py +++ b/backend/tests/test_tags.py @@ -5,7 +5,6 @@ @pytest.fixture def sample_tag(): - """Тестовое событие""" return TagCreate( id="release", description="Some tag description.", @@ -13,14 +12,12 @@ def sample_tag(): def test_create_tag(auth_client, sample_tag): - """Тест на создание события""" response = auth_client.post("/v1/tags/", json=sample_tag.model_dump()) assert response.status_code == 201 assert response.json()["id"] == sample_tag.id def test_get_tag(auth_client, sample_tag): - """Тест на получение события""" auth_client.post("/v1/tags/", json=sample_tag.model_dump()) response = auth_client.get(f"/v1/tags/{sample_tag.id}") assert response.status_code == 200