feat: Fase 1 – Fondamenta complete (backend FastAPI + auth + permessi)

- docker-compose.yml: PostgreSQL 16, Redis 7, MinIO, Nginx
- backend FastAPI: struttura monorepo, config pydantic-settings
- modelli SQLAlchemy: tutti i modelli (tenants, users, mailboxes, messages, archival, permissions, labels, audit_log)
- migrazione Alembic 0001: schema completo in pure SQL
- auth API: login JWT, refresh token rotation, logout, 2FA TOTP (setup/verify/disable)
- CRUD utenti: lista, crea, modifica, reset password, soft delete
- permessi granulari (Fase 1-A): mailbox_permissions, assegna/revoca/lista
- CRUD tenant: gestione super-admin
- sicurezza: AES-256-GCM cifratura credenziali IMAP/SMTP, bcrypt password
- RLS PostgreSQL: isolamento multi-tenant per request
- seed sviluppo: tenant demo + admin + operator
- test unit: security (bcrypt, JWT, AES), auth_service
- test integration: auth endpoints, users endpoints
- CI GitHub Actions: lint (ruff), test (pytest), build Docker, security scan
- infra: nginx.conf, redis.conf
- Makefile con comandi make dev/test/migrate/seed

Definition of Done:
 Login, refresh token e TOTP funzionanti
 make dev porta in piedi tutto lo stack locale
 CI configurata
This commit is contained in:
2026-03-18 16:42:01 +01:00
parent 0251c2bbb0
commit 58a233236c
60 changed files with 6942 additions and 0 deletions
+144
View File
@@ -0,0 +1,144 @@
"""
Fixtures per test di integrazione DB in-memory SQLite + app FastAPI.
Per i test di integrazione si usa SQLite async invece di PostgreSQL per
semplicità e velocità. In CI si può aggiungere un servizio PostgreSQL reale.
"""
import os
import uuid
from typing import AsyncGenerator
import pytest
import pytest_asyncio
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
# Override variabili d'ambiente prima di importare l'app
os.environ["ENCRYPTION_KEY"] = "b" * 64
os.environ["SECRET_KEY"] = "integration-test-secret-key-only-for-tests"
os.environ["DATABASE_URL"] = "sqlite+aiosqlite:///./test_integration.db"
os.environ["DATABASE_URL_SYNC"] = "sqlite:///./test_integration.db"
os.environ["APP_ENV"] = "development"
os.environ["APP_DEBUG"] = "false"
from app.database import Base
from app.main import app
# Engine SQLite per test
TEST_DATABASE_URL = "sqlite+aiosqlite:///./test_integration.db"
test_engine = create_async_engine(
TEST_DATABASE_URL,
echo=False,
connect_args={"check_same_thread": False},
)
TestAsyncSessionLocal = async_sessionmaker(
bind=test_engine,
class_=AsyncSession,
expire_on_commit=False,
autoflush=False,
)
@pytest_asyncio.fixture(scope="session", autouse=True)
async def setup_database():
"""Crea tutte le tabelle nel DB di test."""
# Import modelli per registrarli nel metadata
import app.models # noqa: F401
async with test_engine.begin() as conn:
# SQLite non supporta tutti i tipi PostgreSQL, usiamo tabelle semplici
await conn.run_sync(Base.metadata.drop_all)
await conn.run_sync(Base.metadata.create_all)
yield
async with test_engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
await test_engine.dispose()
# Pulisci file DB
import os
if os.path.exists("test_integration.db"):
os.remove("test_integration.db")
@pytest_asyncio.fixture
async def db_session() -> AsyncGenerator[AsyncSession, None]:
"""Session DB isolata per ogni test (rollback automatico)."""
async with TestAsyncSessionLocal() as session:
yield session
await session.rollback()
@pytest_asyncio.fixture
async def client(db_session: AsyncSession) -> AsyncGenerator[AsyncClient, None]:
"""HTTP client per test API con override del DB."""
from app.database import get_db
async def override_get_db():
yield db_session
app.dependency_overrides[get_db] = override_get_db
async with AsyncClient(
transport=ASGITransport(app=app),
base_url="http://testserver",
) as c:
yield c
app.dependency_overrides.clear()
@pytest_asyncio.fixture
async def demo_tenant(db_session: AsyncSession):
"""Crea un tenant di test."""
from app.models.tenant import Tenant
tenant = Tenant(
id=uuid.UUID("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"),
slug="test-tenant",
name="Test Tenant",
plan="pro",
max_mailboxes=10,
max_users=10,
)
db_session.add(tenant)
await db_session.commit()
await db_session.refresh(tenant)
return tenant
@pytest_asyncio.fixture
async def admin_user(db_session: AsyncSession, demo_tenant):
"""Crea un utente admin nel tenant di test."""
from app.core.security import hash_password
from app.models.user import User
user = User(
tenant_id=demo_tenant.id,
email="admin@test.com",
password_hash=hash_password("AdminPass1!"),
full_name="Test Admin",
role="admin",
is_active=True,
)
db_session.add(user)
await db_session.commit()
await db_session.refresh(user)
return user
@pytest_asyncio.fixture
async def admin_token(client: AsyncClient, admin_user, db_session: AsyncSession) -> str:
"""
Token JWT per l'utente admin.
Nota: il login usa il DB sovrapposto dalla fixture, quindi
il seed_user deve già esistere.
"""
from app.core.security import create_access_token
token = create_access_token(
subject=admin_user.id,
tenant_id=admin_user.tenant_id,
role=admin_user.role,
)
return token