From 02473bc39e920fedb8adba404cc5127e2b2b884b Mon Sep 17 00:00:00 2001 From: Luca Sacchi Ricciardi Date: Tue, 7 Apr 2026 13:52:33 +0200 Subject: [PATCH] feat(schemas): T17 add Pydantic auth schemas Add authentication schemas for user registration and login: - UserRegister: email, password (with strength validation), password_confirm - UserLogin: email, password - UserResponse: id, email, created_at, is_active (orm_mode=True) - TokenResponse: access_token, token_type, expires_in - TokenData: user_id, exp Includes field validators for password strength and password confirmation matching. Test coverage: 19 tests for all schemas --- docs/githistory.md | 29 ++ export/progress.md | 10 +- prompt/prompt-authentication.md | 571 +++++++++++++++++++++ prompt/prompt-ingaggio-authentication.md | 285 ++++++++++ src/openrouter_monitor/schemas/__init__.py | 16 + src/openrouter_monitor/schemas/auth.py | 173 +++++++ tests/unit/schemas/test_auth_schemas.py | 262 ++++++++++ 7 files changed, 1341 insertions(+), 5 deletions(-) create mode 100644 docs/githistory.md create mode 100644 prompt/prompt-authentication.md create mode 100644 prompt/prompt-ingaggio-authentication.md create mode 100644 src/openrouter_monitor/schemas/__init__.py create mode 100644 src/openrouter_monitor/schemas/auth.py create mode 100644 tests/unit/schemas/test_auth_schemas.py diff --git a/docs/githistory.md b/docs/githistory.md new file mode 100644 index 0000000..e2e4cab --- /dev/null +++ b/docs/githistory.md @@ -0,0 +1,29 @@ + +## 2026-04-07: Security Services Implementation (T12-T16) + +### Commits + +- `2fdd9d1` feat(security): T12 implement AES-256 encryption service +- `54e8116` feat(security): T13 implement bcrypt password hashing +- `781e564` feat(security): T14 implement JWT utilities +- `649ff76` feat(security): T15 implement API token generation +- `a698d09` feat(security): T16 finalize security services exports + +### Contenuto + +Implementazione completa dei servizi di sicurezza con TDD: +- EncryptionService (AES-256-GCM con PBKDF2HMAC) +- Password hashing (bcrypt 12 rounds) con strength validation +- JWT utilities (HS256) con create/decode/verify +- API token generation (SHA-256) con timing-safe comparison + +### Statistiche + +- 70 test passanti +- 100% coverage su tutti i moduli security +- 5 commit atomici seguendo conventional commits + +### Note + +Tutti i test sono stati scritti prima del codice (TDD puro). +Ogni servizio ha test per casi di successo, errori, e edge cases. diff --git a/export/progress.md b/export/progress.md index 07b139b..4af7288 100644 --- a/export/progress.md +++ b/export/progress.md @@ -9,11 +9,11 @@ | Metrica | Valore | |---------|--------| | **Stato** | ๐ŸŸข Security Services Completati | -| **Progresso** | 20% | +| **Progresso** | 23% | | **Data Inizio** | 2024-04-07 | | **Data Target** | TBD | | **Task Totali** | 74 | -| **Task Completati** | 16 | +| **Task Completati** | 17 | | **Task In Progress** | 0 | --- @@ -63,9 +63,9 @@ **Test totali servizi:** 71 test passanti **Coverage servizi:** 100% -### ๐Ÿ‘ค Autenticazione Utenti (T17-T22) - 0/6 completati -- [ ] T17: Creare Pydantic schemas auth (register/login) -- [ ] T18: Implementare endpoint POST /api/auth/register +### ๐Ÿ‘ค Autenticazione Utenti (T17-T22) - 1/6 completati +- [x] T17: Creare Pydantic schemas auth (register/login) - โœ… Completato (2026-04-07 14:30) +- [ ] T18: Implementare endpoint POST /api/auth/register - ๐ŸŸก In progress - [ ] T19: Implementare endpoint POST /api/auth/login - [ ] T20: Implementare endpoint POST /api/auth/logout - [ ] T21: Creare dipendenza get_current_user diff --git a/prompt/prompt-authentication.md b/prompt/prompt-authentication.md new file mode 100644 index 0000000..5d00431 --- /dev/null +++ b/prompt/prompt-authentication.md @@ -0,0 +1,571 @@ +# Prompt: User Authentication Implementation (T17-T22) + +## ๐ŸŽฏ OBIETTIVO + +Implementare la fase **User Authentication** del progetto OpenRouter API Key Monitor seguendo rigorosamente TDD (Test-Driven Development). + +**Task da completare:** T17, T18, T19, T20, T21, T22 + +--- + +## ๐Ÿ“‹ CONTESTO + +- **Repository:** `/home/google/Sources/LucaSacchiNet/openrouter-watcher` +- **Specifiche:** `/home/google/Sources/LucaSacchiNet/openrouter-watcher/export/architecture.md` (sezioni 4, 5) +- **Kanban:** `/home/google/Sources/LucaSacchiNet/openrouter-watcher/export/kanban.md` +- **Stato Attuale:** Security Services completati (T01-T16), 202 test passanti +- **Progresso:** 22% (16/74 task) +- **Servizi Pronti:** Encryption, Password hashing, JWT, API Token + +--- + +## ๐Ÿ”ง TASK DETTAGLIATI + +### T17: Creare Pydantic Schemas per Autenticazione + +**Requisiti:** +- Creare `src/openrouter_monitor/schemas/auth.py` +- Schemas per request/response: + - `UserRegister`: email, password, password_confirm + - `UserLogin`: email, password + - `UserResponse`: id, email, created_at, is_active + - `TokenResponse`: access_token, token_type, expires_in + - `TokenData`: user_id (sub), exp +- Validazione password strength (richiama `validate_password_strength`) +- Validazione email formato valido +- Validazione password e password_confirm coincidono + +**Implementazione:** +```python +from pydantic import BaseModel, EmailStr, Field, validator, root_validator + +class UserRegister(BaseModel): + email: EmailStr + password: str = Field(..., min_length=12, max_length=128) + password_confirm: str + + @validator('password') + def password_strength(cls, v): + from openrouter_monitor.services.password import validate_password_strength + if not validate_password_strength(v): + raise ValueError('Password does not meet strength requirements') + return v + + @root_validator + def passwords_match(cls, values): + if values.get('password') != values.get('password_confirm'): + raise ValueError('Passwords do not match') + return values + +class UserLogin(BaseModel): + email: EmailStr + password: str + +class UserResponse(BaseModel): + id: int + email: str + created_at: datetime + is_active: bool + + class Config: + orm_mode = True + +class TokenResponse(BaseModel): + access_token: str + token_type: str = "bearer" + expires_in: int + +class TokenData(BaseModel): + user_id: int | None = None +``` + +**Test richiesti:** +- Test UserRegister valido +- Test UserRegister password troppo corta +- Test UserRegister password e confirm non coincidono +- Test UserRegister email invalida +- Test UserLogin valido +- Test UserResponse orm_mode + +--- + +### T18: Implementare Endpoint POST /api/auth/register + +**Requisiti:** +- Creare `src/openrouter_monitor/routers/auth.py` +- Endpoint: `POST /api/auth/register` +- Riceve `UserRegister` schema +- Verifica email non esista giร  nel DB +- Hash password con `hash_password()` +- Crea utente nel database +- Ritorna `UserResponse` con status 201 +- Gestire errori: email esistente (400), validazione fallita (422) + +**Implementazione:** +```python +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session + +router = APIRouter(prefix="/api/auth", tags=["auth"]) + +@router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED) +async def register(user_data: UserRegister, db: Session = Depends(get_db)): + # Verifica email esistente + existing = db.query(User).filter(User.email == user_data.email).first() + if existing: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Email already registered" + ) + + # Crea utente + hashed_password = hash_password(user_data.password) + user = User(email=user_data.email, password_hash=hashed_password) + db.add(user) + db.commit() + db.refresh(user) + + return user +``` + +**Test richiesti:** +- Test registrazione nuovo utente successo +- Test registrazione email esistente fallisce +- Test registrazione password debole fallisce +- Test registrazione email invalida fallisce + +--- + +### T19: Implementare Endpoint POST /api/auth/login + +**Requisiti:** +- Endpoint: `POST /api/auth/login` +- Riceve `UserLogin` schema +- Verifica esistenza utente per email +- Verifica password con `verify_password()` +- Genera JWT con `create_access_token()` +- Ritorna `TokenResponse` con access_token +- Gestire errori: credenziali invalide (401) + +**Implementazione:** +```python +@router.post("/login", response_model=TokenResponse) +async def login(credentials: UserLogin, db: Session = Depends(get_db)): + # Trova utente + user = db.query(User).filter(User.email == credentials.email).first() + if not user or not user.is_active: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid credentials" + ) + + # Verifica password + if not verify_password(credentials.password, user.password_hash): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid credentials" + ) + + # Genera JWT + access_token = create_access_token(data={"sub": str(user.id)}) + + return TokenResponse( + access_token=access_token, + expires_in=3600 # 1 ora + ) +``` + +**Test richiesti:** +- Test login con credenziali valide successo +- Test login con email inesistente fallisce +- Test login con password sbagliata fallisce +- Test login utente disattivato fallisce +- Test JWT contiene user_id corretto + +--- + +### T20: Implementare Endpoint POST /api/auth/logout + +**Requisiti:** +- Endpoint: `POST /api/auth/logout` +- Richiede autenticazione (JWT valido) +- In una implementazione JWT stateless, logout รจ gestito lato client +- Aggiungere token a blacklist (opzionale per MVP) +- Ritorna 200 con messaggio di successo + +**Implementazione:** +```python +@router.post("/logout") +async def logout(current_user: User = Depends(get_current_user)): + # In JWT stateless, il logout รจ gestito rimuovendo il token lato client + # Per implementazione con blacklist, aggiungere token a lista nera + return {"message": "Successfully logged out"} +``` + +**Test richiesti:** +- Test logout con token valido successo +- Test logout senza token fallisce (401) +- Test logout con token invalido fallisce (401) + +--- + +### T21: Implementare Dipendenza get_current_user + +**Requisiti:** +- Creare `src/openrouter_monitor/dependencies/auth.py` +- Implementare `get_current_user()` per FastAPI dependency injection +- Estrae JWT da header Authorization (Bearer token) +- Verifica token con `verify_token()` o `decode_access_token()` +- Recupera utente dal DB per user_id nel token +- Verifica utente esista e sia attivo +- Gestire errori: token mancante, invalido, scaduto, utente non trovato + +**Implementazione:** +```python +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials + +security = HTTPBearer() + +async def get_current_user( + credentials: HTTPAuthorizationCredentials = Depends(security), + db: Session = Depends(get_db) +) -> User: + token = credentials.credentials + + try: + payload = decode_access_token(token) + user_id = int(payload.get("sub")) + if user_id is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid token payload" + ) + except JWTError: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid or expired token" + ) + + user = db.query(User).filter(User.id == user_id).first() + if user is None or not user.is_active: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User not found or inactive" + ) + + return user + +async def get_current_active_user(current_user: User = Depends(get_current_user)) -> User: + if not current_user.is_active: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Inactive user" + ) + return current_user +``` + +**Test richiesti:** +- Test get_current_user con token valido ritorna utente +- Test get_current_user senza token fallisce +- Test get_current_user con token scaduto fallisce +- Test get_current_user con token invalido fallisce +- Test get_current_user utente non esiste fallisce +- Test get_current_user utente inattivo fallisce + +--- + +### T22: Scrivere Test per Auth Endpoints + +**Requisiti:** +- Creare `tests/unit/routers/test_auth.py` +- Test integrazione per tutti gli endpoint auth +- Test con TestClient di FastAPI +- Mock database per test isolati +- Coverage >= 90% + +**Test richiesti:** +- **Register Tests:** + - POST /api/auth/register successo (201) + - POST /api/auth/register email duplicata (400) + - POST /api/auth/register password debole (422) + +- **Login Tests:** + - POST /api/auth/login successo (200 + token) + - POST /api/auth/login credenziali invalide (401) + - POST /api/auth/login utente inattivo (401) + +- **Logout Tests:** + - POST /api/auth/logout successo (200) + - POST /api/auth/logout senza token (401) + +- **get_current_user Tests:** + - Accesso protetto con token valido + - Accesso protetto senza token (401) + - Accesso protetto token scaduto (401) + +--- + +## ๐Ÿ”„ WORKFLOW TDD OBBLIGATORIO + +Per OGNI task (T17-T22): + +``` +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ 1. RED - Scrivi il test che fallisce โ”‚ +โ”‚ โ€ข Test prima del codice โ”‚ +โ”‚ โ€ข Pattern AAA (Arrange-Act-Assert) โ”‚ +โ”‚ โ€ข Nomi descrittivi โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ†“ +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ 2. GREEN - Implementa codice minimo โ”‚ +โ”‚ โ€ข Solo codice necessario per test โ”‚ +โ”‚ โ€ข Nessun refactoring ancora โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ†“ +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ 3. REFACTOR - Migliora il codice โ”‚ +โ”‚ โ€ข Pulisci duplicazioni โ”‚ +โ”‚ โ€ข Migliora nomi variabili โ”‚ +โ”‚ โ€ข Test rimangono verdi โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +``` + +--- + +## ๐Ÿ“ STRUTTURA FILE DA CREARE + +``` +src/openrouter_monitor/ +โ”œโ”€โ”€ schemas/ +โ”‚ โ”œโ”€โ”€ __init__.py # Esporta tutti gli schemas +โ”‚ โ””โ”€โ”€ auth.py # T17 - Auth schemas +โ”œโ”€โ”€ routers/ +โ”‚ โ”œโ”€โ”€ __init__.py # Include auth router +โ”‚ โ””โ”€โ”€ auth.py # T18, T19, T20 - Auth endpoints +โ””โ”€โ”€ dependencies/ + โ”œโ”€โ”€ __init__.py + โ””โ”€โ”€ auth.py # T21 - get_current_user + +tests/unit/ +โ”œโ”€โ”€ schemas/ +โ”‚ โ”œโ”€โ”€ __init__.py +โ”‚ โ””โ”€โ”€ test_auth_schemas.py # T17 + T22 +โ””โ”€โ”€ routers/ + โ”œโ”€โ”€ __init__.py + โ””โ”€โ”€ test_auth.py # T18, T19, T20, T21 + T22 +``` + +--- + +## ๐Ÿงช REQUISITI TEST + +### Pattern AAA (Arrange-Act-Assert) + +```python +@pytest.mark.asyncio +async def test_register_new_user_returns_201_and_user_data(): + # Arrange + user_data = { + "email": "test@example.com", + "password": "SecurePass123!", + "password_confirm": "SecurePass123!" + } + + # Act + response = client.post("/api/auth/register", json=user_data) + + # Assert + assert response.status_code == 201 + assert response.json()["email"] == user_data["email"] + assert "id" in response.json() +``` + +### Marker Pytest + +```python +@pytest.mark.unit # Logica pura +@pytest.mark.integration # Con database +@pytest.mark.asyncio # Funzioni async +@pytest.mark.auth # Test autenticazione +``` + +### Fixtures Condivise + +```python +@pytest.fixture +def test_user(db_session): + """Create test user in database.""" + user = User( + email="test@example.com", + password_hash=hash_password("SecurePass123!") + ) + db_session.add(user) + db_session.commit() + return user + +@pytest.fixture +def auth_token(test_user): + """Generate valid JWT for test user.""" + return create_access_token(data={"sub": str(test_user.id)}) + +@pytest.fixture +def authorized_client(client, auth_token): + """Client with authorization header.""" + client.headers["Authorization"] = f"Bearer {auth_token}" + return client +``` + +--- + +## ๐Ÿ›ก๏ธ VINCOLI TECNICI + +### Pydantic Schemas Requirements + +```python +# Validazione password strength +def validate_password_strength(cls, v): + from openrouter_monitor.services.password import validate_password_strength + if not validate_password_strength(v): + raise ValueError( + 'Password must be at least 12 characters with uppercase, ' + 'lowercase, digit, and special character' + ) + return v + +# Validazione passwords match +@root_validator +def passwords_match(cls, values): + if values.get('password') != values.get('password_confirm'): + raise ValueError('Passwords do not match') + return values +``` + +### Router Requirements + +```python +from fastapi import APIRouter, Depends, HTTPException, status + +router = APIRouter(prefix="/api/auth", tags=["auth"]) + +@router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED) +async def register(...): + ... + +@router.post("/login", response_model=TokenResponse) +async def login(...): + ... + +@router.post("/logout") +async def logout(...): + ... +``` + +### Dependency Requirements + +```python +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials + +security = HTTPBearer() + +async def get_current_user( + credentials: HTTPAuthorizationCredentials = Depends(security), + db: Session = Depends(get_db) +) -> User: + """Extract and validate JWT, return current user.""" + ... +``` + +--- + +## ๐Ÿ“Š AGGIORNAMENTO PROGRESS + +Dopo ogni task completato, aggiorna: +`/home/google/Sources/LucaSacchiNet/openrouter-watcher/export/progress.md` + +Esempio: +```markdown +### ๐Ÿ‘ค Autenticazione Utenti (T17-T22) + +- [x] T17: Pydantic schemas auth - Completato [timestamp] +- [x] T18: Endpoint POST /api/auth/register - Completato [timestamp] +- [ ] T19: Endpoint POST /api/auth/login - In progress +- [ ] T20: Endpoint POST /api/auth/logout +- [ ] T21: Dipendenza get_current_user +- [ ] T22: Test auth endpoints + +**Progresso sezione:** 33% (2/6 task) +**Progresso totale:** 24% (18/74 task) +``` + +--- + +## โœ… CRITERI DI ACCETTAZIONE + +- [ ] T17: Schemas auth completi con validazione +- [ ] T18: Endpoint /api/auth/register funzionante (201/400) +- [ ] T19: Endpoint /api/auth/login funzionante (200/401) +- [ ] T20: Endpoint /api/auth/logout funzionante +- [ ] T21: get_current_user dependency funzionante +- [ ] T22: Test completi per auth (coverage >= 90%) +- [ ] Tutti i test passano (`pytest tests/unit/routers/test_auth.py`) +- [ ] Nessuna password in plaintext nei log/errori +- [ ] 6 commit atomici (uno per task) +- [ ] progress.md aggiornato + +--- + +## ๐Ÿš€ COMANDO DI VERIFICA + +Al termine, esegui: +```bash +cd /home/google/Sources/LucaSacchiNet/openrouter-watcher +pytest tests/unit/schemas/test_auth_schemas.py -v +pytest tests/unit/routers/test_auth.py -v --cov=src/openrouter_monitor/routers + +# Verifica endpoint con curl +curl -X POST http://localhost:8000/api/auth/register \ + -H "Content-Type: application/json" \ + -d '{"email":"test@example.com","password":"SecurePass123!","password_confirm":"SecurePass123!"}' + +curl -X POST http://localhost:8000/api/auth/login \ + -H "Content-Type: application/json" \ + -d '{"email":"test@example.com","password":"SecurePass123!"}' +``` + +--- + +## ๐Ÿ”’ CONSIDERAZIONI SICUREZZA + +### Do's โœ… +- Usare `get_current_user` per proteggere endpoint +- Non loggare mai password in plaintext +- Ritornare errori generici per credenziali invalide +- Usare HTTPS in produzione +- Validare tutti gli input con Pydantic + +### Don'ts โŒ +- MAI ritornare password hash nelle response +- MAI loggare token JWT completi +- MAI usare GET per operazioni che modificano dati +- MAI ignorare eccezioni di autenticazione + +--- + +## ๐Ÿ“ NOTE + +- Usa SEMPRE path assoluti: `/home/google/Sources/LucaSacchiNet/openrouter-watcher/` +- Segui le convenzioni in `.opencode/agents/tdd-developer.md` +- Task devono essere verificabili in < 2 ore ciascuno +- Documenta bug complessi in `/docs/bug_ledger.md` +- Usa conventional commits: + - `feat(schemas): T17 add Pydantic auth schemas` + - `feat(auth): T18 implement user registration endpoint` + - `feat(auth): T19 implement user login endpoint` + - `feat(auth): T20 implement user logout endpoint` + - `feat(deps): T21 implement get_current_user dependency` + - `test(auth): T22 add comprehensive auth endpoint tests` + +**AGENTE:** @tdd-developer +**INIZIA CON:** T17 - Pydantic schemas diff --git a/prompt/prompt-ingaggio-authentication.md b/prompt/prompt-ingaggio-authentication.md new file mode 100644 index 0000000..e5f7b68 --- /dev/null +++ b/prompt/prompt-ingaggio-authentication.md @@ -0,0 +1,285 @@ +# Prompt di Ingaggio: User Authentication (T17-T22) + +## ๐ŸŽฏ MISSIONE + +Implementare la fase **User Authentication** (T17-T22) del progetto OpenRouter API Key Monitor seguendo rigorosamente TDD. + +--- + +## ๐Ÿ“‹ CONTEXTO + +**AGENTE:** @tdd-developer + +**Repository:** `/home/google/Sources/LucaSacchiNet/openrouter-watcher` + +**Stato Attuale:** +- โœ… Setup completato (T01-T05): 59 test +- โœ… Database & Models (T06-T11): 73 test +- โœ… Security Services (T12-T16): 70 test +- ๐ŸŽฏ **Totale: 202 test passanti, 100% coverage sui moduli implementati** + +**Servizi Pronti da utilizzare:** +- `hash_password()`, `verify_password()` - in `services/password.py` +- `create_access_token()`, `decode_access_token()` - in `services/jwt.py` +- `EncryptionService` - in `services/encryption.py` +- `generate_api_token()`, `verify_api_token()` - in `services/token.py` +- `User`, `ApiKey`, `UsageStats`, `ApiToken` models +- `get_db()`, `Base` - in `database.py` + +**Documentazione:** +- PRD: `/home/google/Sources/LucaSacchiNet/openrouter-watcher/prd.md` +- Architecture: `/home/google/Sources/LucaSacchiNet/openrouter-watcher/export/architecture.md` +- Prompt Dettagliato: `/home/google/Sources/LucaSacchiNet/openrouter-watcher/prompt/prompt-authentication.md` + +--- + +## ๐Ÿ”ง TASK DA IMPLEMENTARE + +### T17: Creare Pydantic Schemas per Autenticazione + +**File:** `src/openrouter_monitor/schemas/auth.py` + +**Requisiti:** +- `UserRegister`: email (EmailStr), password (min 12), password_confirm + - Validatore: richiama `validate_password_strength()` + - Root validator: password == password_confirm +- `UserLogin`: email, password +- `UserResponse`: id, email, created_at, is_active (orm_mode=True) +- `TokenResponse`: access_token, token_type="bearer", expires_in +- `TokenData`: user_id (sub), exp + +**Test:** `tests/unit/schemas/test_auth_schemas.py` + +--- + +### T18: Implementare Endpoint POST /api/auth/register + +**File:** `src/openrouter_monitor/routers/auth.py` + +**Requisiti:** +- Endpoint: `POST /api/auth/register` +- Riceve: `UserRegister` schema +- Logica: + 1. Verifica email non esista: `db.query(User).filter(User.email == ...).first()` + 2. Se esiste: HTTPException 400 "Email already registered" + 3. Hash password: `hash_password(user_data.password)` + 4. Crea User: `User(email=..., password_hash=...)` + 5. Salva: `db.add()`, `db.commit()`, `db.refresh()` + 6. Ritorna: `UserResponse`, status 201 + +**Test:** Register success, email duplicata (400), password debole (422) + +--- + +### T19: Implementare Endpoint POST /api/auth/login + +**File:** `src/openrouter_monitor/routers/auth.py` + +**Requisiti:** +- Endpoint: `POST /api/auth/login` +- Riceve: `UserLogin` schema +- Logica: + 1. Trova utente per email + 2. Se non trovato o inattivo: HTTPException 401 "Invalid credentials" + 3. Verifica password: `verify_password(credentials.password, user.password_hash)` + 4. Se fallita: HTTPException 401 + 5. Genera JWT: `create_access_token(data={"sub": str(user.id)})` + 6. Ritorna: `TokenResponse` con access_token + +**Test:** Login success (200 + token), email inesistente (401), password sbagliata (401), utente inattivo (401) + +--- + +### T20: Implementare Endpoint POST /api/auth/logout + +**File:** `src/openrouter_monitor/routers/auth.py` + +**Requisiti:** +- Endpoint: `POST /api/auth/logout` +- Richiede: `current_user: User = Depends(get_current_user)` +- Logica: JWT stateless, logout gestito lato client +- Ritorna: `{"message": "Successfully logged out"}` + +**Test:** Logout con token valido (200), senza token (401) + +--- + +### T21: Implementare Dipendenza get_current_user + +**File:** `src/openrouter_monitor/dependencies/auth.py` + +**Requisiti:** +```python +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials + +security = HTTPBearer() + +async def get_current_user( + credentials: HTTPAuthorizationCredentials = Depends(security), + db: Session = Depends(get_db) +) -> User: + token = credentials.credentials + try: + payload = decode_access_token(token) + user_id = int(payload.get("sub")) + if not user_id: + raise HTTPException(401, "Invalid token payload") + except JWTError: + raise HTTPException(401, "Invalid or expired token") + + user = db.query(User).filter(User.id == user_id).first() + if not user or not user.is_active: + raise HTTPException(401, "User not found or inactive") + return user +``` + +**Test:** Token valido ritorna utente, token mancante (401), token scaduto (401), token invalido (401), utente inesistente (401), utente inattivo (401) + +--- + +### T22: Scrivere Test per Auth Endpoints + +**File:** `tests/unit/routers/test_auth.py` + +**Requisiti:** +- Usare `TestClient` da FastAPI +- Fixture: `test_user`, `auth_token`, `authorized_client` +- Test coverage >= 90% + +**Test da implementare:** +- Register: success (201), email duplicata (400), password debole (422), email invalida (422) +- Login: success (200 + token), email inesistente (401), password sbagliata (401), utente inattivo (401) +- Logout: success (200), senza token (401) +- get_current_user: protetto con token valido, senza token (401), token scaduto (401) + +--- + +## ๐Ÿ”„ WORKFLOW TDD + +Per **OGNI** task: + +1. **RED**: Scrivi test che fallisce (prima del codice!) +2. **GREEN**: Implementa codice minimo per passare il test +3. **REFACTOR**: Migliora codice, test rimangono verdi + +--- + +## ๐Ÿ“ STRUTTURA FILE DA CREARE + +``` +src/openrouter_monitor/ +โ”œโ”€โ”€ schemas/ +โ”‚ โ”œโ”€โ”€ __init__.py +โ”‚ โ””โ”€โ”€ auth.py # T17 +โ”œโ”€โ”€ routers/ +โ”‚ โ”œโ”€โ”€ __init__.py +โ”‚ โ””โ”€โ”€ auth.py # T18, T19, T20 +โ””โ”€โ”€ dependencies/ + โ”œโ”€โ”€ __init__.py + โ””โ”€โ”€ auth.py # T21 + +tests/unit/ +โ”œโ”€โ”€ schemas/ +โ”‚ โ”œโ”€โ”€ __init__.py +โ”‚ โ””โ”€โ”€ test_auth_schemas.py # T17 + T22 +โ””โ”€โ”€ routers/ + โ”œโ”€โ”€ __init__.py + โ””โ”€โ”€ test_auth.py # T18-T21 + T22 +``` + +--- + +## ๐Ÿงช ESEMPI TEST + +### Test Schema +```python +@pytest.mark.unit +def test_user_register_valid_data_passes_validation(): + data = UserRegister( + email="test@example.com", + password="SecurePass123!", + password_confirm="SecurePass123!" + ) + assert data.email == "test@example.com" +``` + +### Test Endpoint +```python +@pytest.mark.asyncio +async def test_register_new_user_returns_201(client, db_session): + response = client.post("/api/auth/register", json={ + "email": "test@example.com", + "password": "SecurePass123!", + "password_confirm": "SecurePass123!" + }) + assert response.status_code == 201 + assert response.json()["email"] == "test@example.com" +``` + +--- + +## โœ… CRITERI DI ACCETTAZIONE + +- [ ] T17: Schemas auth con validazione completa +- [ ] T18: POST /api/auth/register (201/400/422) +- [ ] T19: POST /api/auth/login (200/401) +- [ ] T20: POST /api/auth/logout (200) +- [ ] T21: get_current_user dependency funzionante +- [ ] T22: Test auth coverage >= 90% +- [ ] Tutti i test passano: `pytest tests/unit/routers/test_auth.py -v` +- [ ] 6 commit atomici con conventional commits +- [ ] progress.md aggiornato + +--- + +## ๐Ÿ“ COMMIT MESSAGES + +``` +feat(schemas): T17 add Pydantic auth schemas + +feat(auth): T18 implement user registration endpoint + +feat(auth): T19 implement user login endpoint + +feat(auth): T20 implement user logout endpoint + +feat(deps): T21 implement get_current_user dependency + +test(auth): T22 add comprehensive auth endpoint tests +``` + +--- + +## ๐Ÿš€ VERIFICA FINALE + +```bash +cd /home/google/Sources/LucaSacchiNet/openrouter-watcher + +# Test schemas +pytest tests/unit/schemas/test_auth_schemas.py -v + +# Test routers +pytest tests/unit/routers/test_auth.py -v --cov=src/openrouter_monitor/routers + +# Test completo +pytest tests/unit/ -v --cov=src/openrouter_monitor +``` + +--- + +## โš ๏ธ NOTE IMPORTANTI + +- **Path assoluti**: Usa sempre `/home/google/Sources/LucaSacchiNet/openrouter-watcher/` +- **Servizi esistenti**: Riutilizza `hash_password`, `verify_password`, `create_access_token`, `decode_access_token` +- **Database**: Usa `get_db()` da `database.py` per dependency injection +- **Models**: Importa da `models` package (User, ApiKey, etc.) +- **Sicurezza**: Mai loggare password o token in plaintext +- **Errori**: Errori generici per credenziali invalide (non leakare info) + +--- + +**AGENTE:** @tdd-developer + +**INIZIA CON:** T17 - Pydantic schemas + +**QUANDO FINITO:** Conferma completamento e aggiorna progress.md diff --git a/src/openrouter_monitor/schemas/__init__.py b/src/openrouter_monitor/schemas/__init__.py new file mode 100644 index 0000000..7af15c1 --- /dev/null +++ b/src/openrouter_monitor/schemas/__init__.py @@ -0,0 +1,16 @@ +"""Schemas package for OpenRouter Monitor.""" +from openrouter_monitor.schemas.auth import ( + TokenData, + TokenResponse, + UserLogin, + UserRegister, + UserResponse, +) + +__all__ = [ + "UserRegister", + "UserLogin", + "UserResponse", + "TokenResponse", + "TokenData", +] diff --git a/src/openrouter_monitor/schemas/auth.py b/src/openrouter_monitor/schemas/auth.py new file mode 100644 index 0000000..17f7acd --- /dev/null +++ b/src/openrouter_monitor/schemas/auth.py @@ -0,0 +1,173 @@ +"""Authentication Pydantic schemas. + +T17: Pydantic schemas for user registration, login, and token management. +""" +from datetime import datetime +from typing import Optional, Union + +from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator, model_validator + +from openrouter_monitor.services.password import validate_password_strength + + +class UserRegister(BaseModel): + """Schema for user registration. + + Attributes: + email: User email address (must be valid email format) + password: User password (min 12 chars, must pass strength validation) + password_confirm: Password confirmation (must match password) + """ + + email: EmailStr = Field( + ..., # Required field + description="User email address", + examples=["user@example.com"] + ) + password: str = Field( + ..., + min_length=12, + description="User password (min 12 characters)", + examples=["SecurePass123!"] + ) + password_confirm: str = Field( + ..., + description="Password confirmation", + examples=["SecurePass123!"] + ) + + @field_validator('password') + @classmethod + def validate_password(cls, v: str) -> str: + """Validate password strength. + + Args: + v: The password value to validate + + Returns: + The password if valid + + Raises: + ValueError: If password doesn't meet strength requirements + """ + if not validate_password_strength(v): + raise ValueError( + "Password must be at least 12 characters long and contain " + "at least one uppercase letter, one lowercase letter, " + "one digit, and one special character" + ) + return v + + @model_validator(mode='after') + def check_passwords_match(self) -> 'UserRegister': + """Verify that password and password_confirm match. + + Returns: + The validated model instance + + Raises: + ValueError: If passwords don't match + """ + if self.password != self.password_confirm: + raise ValueError("Passwords do not match") + return self + + +class UserLogin(BaseModel): + """Schema for user login. + + Attributes: + email: User email address + password: User password + """ + + email: EmailStr = Field( + ..., + description="User email address", + examples=["user@example.com"] + ) + password: str = Field( + ..., + description="User password", + examples=["your-password"] + ) + + +class UserResponse(BaseModel): + """Schema for user response (returned to client). + + Attributes: + id: User ID + email: User email address + created_at: User creation timestamp + is_active: Whether the user account is active + """ + + model_config = ConfigDict(from_attributes=True) + + id: int = Field( + ..., + description="User ID", + examples=[1] + ) + email: EmailStr = Field( + ..., + description="User email address", + examples=["user@example.com"] + ) + created_at: datetime = Field( + ..., + description="User creation timestamp", + examples=["2024-01-01T12:00:00"] + ) + is_active: bool = Field( + ..., + description="Whether the user account is active", + examples=[True] + ) + + +class TokenResponse(BaseModel): + """Schema for token response (returned after login). + + Attributes: + access_token: The JWT access token + token_type: Token type (always 'bearer') + expires_in: Token expiration time in seconds + """ + + access_token: str = Field( + ..., + description="JWT access token", + examples=["eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."] + ) + token_type: str = Field( + default="bearer", + description="Token type", + examples=["bearer"] + ) + expires_in: int = Field( + ..., + description="Token expiration time in seconds", + examples=[86400] + ) + + +class TokenData(BaseModel): + """Schema for token payload data. + + Attributes: + user_id: User ID (from 'sub' claim in JWT) + exp: Token expiration timestamp + """ + + user_id: Union[str, int] = Field( + ..., + description="User ID (from JWT 'sub' claim)", + examples=["123"] + ) + exp: datetime = Field( + ..., + description="Token expiration timestamp", + examples=["2024-01-02T12:00:00"] + ) diff --git a/tests/unit/schemas/test_auth_schemas.py b/tests/unit/schemas/test_auth_schemas.py new file mode 100644 index 0000000..b1f067f --- /dev/null +++ b/tests/unit/schemas/test_auth_schemas.py @@ -0,0 +1,262 @@ +"""Tests for authentication Pydantic schemas. + +T17: Test Pydantic schemas for user authentication. +""" +import pytest +from pydantic import ValidationError, EmailStr +from datetime import datetime, timezone + + +class TestUserRegister: + """Tests for UserRegister schema.""" + + def test_valid_registration(self): + """Test valid user registration data.""" + # This will fail until schema is implemented + from openrouter_monitor.schemas.auth import UserRegister + + data = UserRegister( + email="test@example.com", + password="SecurePass123!", + password_confirm="SecurePass123!" + ) + + assert data.email == "test@example.com" + assert data.password == "SecurePass123!" + assert data.password_confirm == "SecurePass123!" + + def test_invalid_email_format(self): + """Test that invalid email format raises ValidationError.""" + from openrouter_monitor.schemas.auth import UserRegister + + with pytest.raises(ValidationError, match="email"): + UserRegister( + email="not-an-email", + password="SecurePass123!", + password_confirm="SecurePass123!" + ) + + def test_password_too_short(self): + """Test that password shorter than 12 chars raises ValidationError.""" + from openrouter_monitor.schemas.auth import UserRegister + + with pytest.raises(ValidationError, match="password"): + UserRegister( + email="test@example.com", + password="Short1!", + password_confirm="Short1!" + ) + + def test_password_missing_uppercase(self): + """Test that password without uppercase raises ValidationError.""" + from openrouter_monitor.schemas.auth import UserRegister + + with pytest.raises(ValidationError, match="password"): + UserRegister( + email="test@example.com", + password="lowercase123!", + password_confirm="lowercase123!" + ) + + def test_password_missing_lowercase(self): + """Test that password without lowercase raises ValidationError.""" + from openrouter_monitor.schemas.auth import UserRegister + + with pytest.raises(ValidationError, match="password"): + UserRegister( + email="test@example.com", + password="UPPERCASE123!", + password_confirm="UPPERCASE123!" + ) + + def test_password_missing_digit(self): + """Test that password without digit raises ValidationError.""" + from openrouter_monitor.schemas.auth import UserRegister + + with pytest.raises(ValidationError, match="password"): + UserRegister( + email="test@example.com", + password="NoDigitsHere!", + password_confirm="NoDigitsHere!" + ) + + def test_password_missing_special_char(self): + """Test that password without special char raises ValidationError.""" + from openrouter_monitor.schemas.auth import UserRegister + + with pytest.raises(ValidationError, match="password"): + UserRegister( + email="test@example.com", + password="NoSpecialChars123", + password_confirm="NoSpecialChars123" + ) + + def test_passwords_do_not_match(self): + """Test that mismatched passwords raise ValidationError.""" + from openrouter_monitor.schemas.auth import UserRegister + + with pytest.raises(ValidationError, match="match"): + UserRegister( + email="test@example.com", + password="SecurePass123!", + password_confirm="DifferentPass123!" + ) + + def test_password_strength_validator_called(self): + """Test that validate_password_strength is called.""" + from openrouter_monitor.schemas.auth import UserRegister + + # Valid password should pass + data = UserRegister( + email="test@example.com", + password="ValidPass123!@#", + password_confirm="ValidPass123!@#" + ) + assert data.password == "ValidPass123!@#" + + +class TestUserLogin: + """Tests for UserLogin schema.""" + + def test_valid_login(self): + """Test valid login credentials.""" + from openrouter_monitor.schemas.auth import UserLogin + + data = UserLogin( + email="test@example.com", + password="anypassword" + ) + + assert data.email == "test@example.com" + assert data.password == "anypassword" + + def test_invalid_email_format(self): + """Test that invalid email format raises ValidationError.""" + from openrouter_monitor.schemas.auth import UserLogin + + with pytest.raises(ValidationError, match="email"): + UserLogin( + email="not-an-email", + password="password" + ) + + def test_empty_password(self): + """Test that empty password is allowed (validation happens elsewhere).""" + from openrouter_monitor.schemas.auth import UserLogin + + data = UserLogin( + email="test@example.com", + password="" + ) + + assert data.password == "" + + +class TestUserResponse: + """Tests for UserResponse schema.""" + + def test_valid_response(self): + """Test valid user response.""" + from openrouter_monitor.schemas.auth import UserResponse + from datetime import datetime + + created_at = datetime(2024, 1, 1, 12, 0, 0) + + data = UserResponse( + id=1, + email="test@example.com", + created_at=created_at, + is_active=True + ) + + assert data.id == 1 + assert data.email == "test@example.com" + assert data.created_at == created_at + assert data.is_active is True + + def test_from_orm(self): + """Test that UserResponse can be created from ORM model.""" + from openrouter_monitor.schemas.auth import UserResponse + from datetime import datetime + + # Mock ORM object + class MockUser: + id = 1 + email = "test@example.com" + created_at = datetime(2024, 1, 1, 12, 0, 0) + is_active = True + + user = UserResponse.model_validate(MockUser()) + + assert user.id == 1 + assert user.email == "test@example.com" + + +class TestTokenResponse: + """Tests for TokenResponse schema.""" + + def test_valid_token_response(self): + """Test valid token response.""" + from openrouter_monitor.schemas.auth import TokenResponse + + data = TokenResponse( + access_token="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + token_type="bearer", + expires_in=3600 + ) + + assert data.access_token == "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." + assert data.token_type == "bearer" + assert data.expires_in == 3600 + + def test_default_token_type(self): + """Test that default token_type is 'bearer'.""" + from openrouter_monitor.schemas.auth import TokenResponse + + data = TokenResponse( + access_token="some_token", + expires_in=3600 + ) + + assert data.token_type == "bearer" + + +class TestTokenData: + """Tests for TokenData schema.""" + + def test_valid_token_data(self): + """Test valid token data.""" + from openrouter_monitor.schemas.auth import TokenData + + exp = datetime.now(timezone.utc) + + data = TokenData( + user_id="123", + exp=exp + ) + + assert data.user_id == "123" + assert data.exp == exp + + def test_user_id_from_sub(self): + """Test that user_id can be extracted from sub claim.""" + from openrouter_monitor.schemas.auth import TokenData + + exp = datetime.now(timezone.utc) + + # TokenData might be created from JWT payload with 'sub' field + data = TokenData(user_id="456", exp=exp) + assert data.user_id == "456" + + def test_user_id_integer_conversion(self): + """Test that user_id handles integer IDs.""" + from openrouter_monitor.schemas.auth import TokenData + + exp = datetime.now(timezone.utc) + + data = TokenData( + user_id=123, # Integer ID + exp=exp + ) + + assert data.user_id == 123