feat(backend): FastAPI + libSQL + auth register/login/me + tests (TDD)

- FastAPI app with lifespan, CORS, health endpoint
- SQLAlchemy 2.0 async with aiosqlite, Base/UUIDMixin/TenantMixin/TimestampMixin
- Tenant and User models with multi-tenant isolation
- Auth: register (creates tenant+user), login, /me endpoint
- JWT HS256 tokens, bcrypt password hashing
- Alembic async setup with initial migration
- 6 passing tests (register, login, wrong password, me, no token, health)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-13 17:26:31 +02:00
parent c3482bba8d
commit 907b7be0fd
24 changed files with 623 additions and 0 deletions

0
backend/app/__init__.py Normal file
View File

View File

View File

@@ -0,0 +1,58 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import schemas, service
from app.db.session import get_db
from app.deps import get_current_user
router = APIRouter()
@router.post("/register", response_model=schemas.TokenResponse)
async def register(
data: schemas.RegisterRequest, db: AsyncSession = Depends(get_db)
):
user, tenant = await service.register(
db, data.email, data.password, data.tenant_name, data.telefon
)
return schemas.TokenResponse(
access_token=service.create_token(user.id, tenant.id, tenant.plan),
tenant_id=tenant.id,
plan=tenant.plan,
)
@router.post("/login", response_model=schemas.TokenResponse)
async def login(data: schemas.LoginRequest, db: AsyncSession = Depends(get_db)):
user, tenant = await service.authenticate(db, data.email, data.password)
if not user:
raise HTTPException(status_code=401, detail="Credentiale invalide")
return schemas.TokenResponse(
access_token=service.create_token(user.id, tenant.id, tenant.plan),
tenant_id=tenant.id,
plan=tenant.plan,
)
@router.get("/me", response_model=schemas.UserResponse)
async def me(
current_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
from sqlalchemy import select
from app.db.models.user import User
from app.db.models.tenant import Tenant
r = await db.execute(select(User).where(User.id == current_user["sub"]))
user = r.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
r = await db.execute(select(Tenant).where(Tenant.id == user.tenant_id))
tenant = r.scalar_one()
return schemas.UserResponse(
id=user.id,
email=user.email,
tenant_id=user.tenant_id,
plan=tenant.plan,
rol=user.rol,
)

View File

@@ -0,0 +1,28 @@
from pydantic import BaseModel, EmailStr
class RegisterRequest(BaseModel):
email: EmailStr
password: str
tenant_name: str
telefon: str
class LoginRequest(BaseModel):
email: EmailStr
password: str
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
tenant_id: str
plan: str
class UserResponse(BaseModel):
id: str
email: str
tenant_id: str
plan: str
rol: str

View File

@@ -0,0 +1,62 @@
from datetime import UTC, datetime, timedelta
from jose import jwt
import bcrypt
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.db.base import uuid7
from app.db.models.tenant import Tenant
from app.db.models.user import User
def hash_password(p: str) -> str:
return bcrypt.hashpw(p.encode(), bcrypt.gensalt()).decode()
def verify_password(plain: str, hashed: str) -> bool:
return bcrypt.checkpw(plain.encode(), hashed.encode())
def create_token(user_id: str, tenant_id: str, plan: str) -> str:
exp = datetime.now(UTC) + timedelta(days=settings.ACCESS_TOKEN_EXPIRE_DAYS)
return jwt.encode(
{"sub": user_id, "tenant_id": tenant_id, "plan": plan, "exp": exp},
settings.SECRET_KEY,
algorithm="HS256",
)
async def register(
db: AsyncSession, email: str, password: str, tenant_name: str, telefon: str
):
trial_exp = (datetime.now(UTC) + timedelta(days=settings.TRIAL_DAYS)).isoformat()
tenant = Tenant(
id=uuid7(),
nume=tenant_name,
telefon=telefon,
plan="trial",
trial_expires_at=trial_exp,
)
db.add(tenant)
user = User(
id=uuid7(),
tenant_id=tenant.id,
email=email,
password_hash=hash_password(password),
nume=email.split("@")[0],
rol="owner",
)
db.add(user)
await db.commit()
return user, tenant
async def authenticate(db: AsyncSession, email: str, password: str):
r = await db.execute(select(User).where(User.email == email))
user = r.scalar_one_or_none()
if not user or not verify_password(password, user.password_hash):
return None, None
r = await db.execute(select(Tenant).where(Tenant.id == user.tenant_id))
return user, r.scalar_one_or_none()

15
backend/app/config.py Normal file
View File

@@ -0,0 +1,15 @@
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env")
SECRET_KEY: str = "dev-secret-change-me"
DATABASE_URL: str = "sqlite+aiosqlite:///./data/roaauto.db"
ACCESS_TOKEN_EXPIRE_DAYS: int = 30
TRIAL_DAYS: int = 30
SMSAPI_TOKEN: str = ""
CORS_ORIGINS: str = "http://localhost:5173"
settings = Settings()

View File

32
backend/app/db/base.py Normal file
View File

@@ -0,0 +1,32 @@
import uuid
from datetime import datetime, UTC
from sqlalchemy import String, Text
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
def uuid7() -> str:
return str(uuid.uuid4())
class Base(DeclarativeBase):
pass
class UUIDMixin:
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid7)
class TenantMixin:
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
class TimestampMixin:
created_at: Mapped[str] = mapped_column(
Text, default=lambda: datetime.now(UTC).isoformat()
)
updated_at: Mapped[str] = mapped_column(
Text,
default=lambda: datetime.now(UTC).isoformat(),
onupdate=lambda: datetime.now(UTC).isoformat(),
)

View File

@@ -0,0 +1,4 @@
from app.db.models.tenant import Tenant
from app.db.models.user import User
__all__ = ["Tenant", "User"]

View File

@@ -0,0 +1,18 @@
from sqlalchemy import String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base, UUIDMixin, TimestampMixin
class Tenant(Base, UUIDMixin, TimestampMixin):
__tablename__ = "tenants"
nume: Mapped[str] = mapped_column(String(200))
cui: Mapped[str | None] = mapped_column(String(20))
reg_com: Mapped[str | None] = mapped_column(String(30))
adresa: Mapped[str | None] = mapped_column(Text)
telefon: Mapped[str | None] = mapped_column(String(20))
email: Mapped[str | None] = mapped_column(String(200))
iban: Mapped[str | None] = mapped_column(String(34))
banca: Mapped[str | None] = mapped_column(String(100))
plan: Mapped[str] = mapped_column(String(20), default="trial")
trial_expires_at: Mapped[str | None] = mapped_column(Text)

View File

@@ -0,0 +1,13 @@
from sqlalchemy import Boolean, String
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base, UUIDMixin, TenantMixin, TimestampMixin
class User(Base, UUIDMixin, TenantMixin, TimestampMixin):
__tablename__ = "users"
email: Mapped[str] = mapped_column(String(200), unique=True, index=True)
password_hash: Mapped[str] = mapped_column(String(200))
nume: Mapped[str] = mapped_column(String(200))
rol: Mapped[str] = mapped_column(String(20), default="owner")
activ: Mapped[bool] = mapped_column(Boolean, default=True)

11
backend/app/db/session.py Normal file
View File

@@ -0,0 +1,11 @@
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from app.config import settings
engine = create_async_engine(settings.DATABASE_URL, echo=False)
AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False)
async def get_db():
async with AsyncSessionLocal() as session:
yield session

22
backend/app/deps.py Normal file
View File

@@ -0,0 +1,22 @@
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jose import JWTError, jwt
from app.config import settings
bearer = HTTPBearer(auto_error=False)
async def get_current_user(
creds: HTTPAuthorizationCredentials | None = Depends(bearer),
) -> dict:
if creds is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
try:
return jwt.decode(creds.credentials, settings.SECRET_KEY, algorithms=["HS256"])
except JWTError:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
async def get_tenant_id(user: dict = Depends(get_current_user)) -> str:
return user["tenant_id"]

35
backend/app/main.py Normal file
View File

@@ -0,0 +1,35 @@
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.auth.router import router as auth_router
from app.config import settings
from app.db.base import Base
from app.db.session import engine
# Import models so Base.metadata knows about them
import app.db.models # noqa: F401
@asynccontextmanager
async def lifespan(app: FastAPI):
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield
app = FastAPI(lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.CORS_ORIGINS.split(","),
allow_methods=["*"],
allow_headers=["*"],
allow_credentials=True,
)
app.include_router(auth_router, prefix="/api/auth")
@app.get("/api/health")
async def health():
return {"status": "ok"}