Merge feature/auth-jwt-multitenant into main — Sprint 2-3 Auth JWT + Multi-tenant (HRT-28)
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
132
API_AUTH.md
Normal file
132
API_AUTH.md
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
# API Auth JWT — Documentation
|
||||||
|
## Sprint 2-3 (HRT-28)
|
||||||
|
|
||||||
|
Base URL: `http://localhost:8792`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Endpoints d'authentification
|
||||||
|
|
||||||
|
### `POST /api/v1/auth/register`
|
||||||
|
Inscription d'un nouvel utilisateur (plan free par défaut).
|
||||||
|
|
||||||
|
**Body JSON:**
|
||||||
|
```json
|
||||||
|
{ "email": "user@example.com", "password": "motdepasse123" }
|
||||||
|
```
|
||||||
|
**Réponse 201:**
|
||||||
|
```json
|
||||||
|
{ "message": "Compte créé avec succès", "user_id": 1 }
|
||||||
|
```
|
||||||
|
**Erreurs:** `400` (email invalide / mot de passe < 8 car.), `409` (email déjà utilisé)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `POST /api/v1/auth/login`
|
||||||
|
Connexion — retourne access_token (15min) + refresh_token (30j).
|
||||||
|
|
||||||
|
**Body JSON:**
|
||||||
|
```json
|
||||||
|
{ "email": "user@example.com", "password": "motdepasse123" }
|
||||||
|
```
|
||||||
|
**Réponse 200:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"access_token": "<JWT>",
|
||||||
|
"refresh_token": "<refresh_JWT>",
|
||||||
|
"token_type": "Bearer",
|
||||||
|
"plan": "free"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `POST /api/v1/auth/refresh`
|
||||||
|
Rotation du refresh token — invalide l'ancien, émet un nouveau.
|
||||||
|
|
||||||
|
**Body JSON:**
|
||||||
|
```json
|
||||||
|
{ "refresh_token": "<refresh_JWT>" }
|
||||||
|
```
|
||||||
|
**Réponse 200:** identique à `/login`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `POST /api/v1/auth/logout`
|
||||||
|
Révocation du refresh token.
|
||||||
|
|
||||||
|
**Body JSON:**
|
||||||
|
```json
|
||||||
|
{ "refresh_token": "<refresh_JWT>" }
|
||||||
|
```
|
||||||
|
**Réponse 200:**
|
||||||
|
```json
|
||||||
|
{ "message": "Déconnexion réussie" }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Routes protégées
|
||||||
|
|
||||||
|
Toutes les routes protégées nécessitent le header:
|
||||||
|
```
|
||||||
|
Authorization: Bearer <access_token>
|
||||||
|
```
|
||||||
|
|
||||||
|
### `GET /api/v1/predictions`
|
||||||
|
| Plan | Accès |
|
||||||
|
|---------|---------------------------------------------|
|
||||||
|
| free | Top 3 uniquement, 1 course/jour |
|
||||||
|
| premium | Toutes les courses + alertes Telegram |
|
||||||
|
| pro | API complète + lien export CSV |
|
||||||
|
|
||||||
|
### `GET /api/v1/predictions/export`
|
||||||
|
Export CSV — **plan pro uniquement** (`403` pour free/premium).
|
||||||
|
|
||||||
|
### `GET /api/v1/subscription/upgrade`
|
||||||
|
Infos sur les plans disponibles et plan courant de l'utilisateur.
|
||||||
|
|
||||||
|
### `GET /api/v1/health`
|
||||||
|
Vérification d'état du service (pas d'auth requise).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sécurité
|
||||||
|
|
||||||
|
- **Passwords:** hashés avec bcrypt (saltRounds=12)
|
||||||
|
- **JWT access:** expiration 15 minutes (HS256)
|
||||||
|
- **JWT refresh:** expiration 30 jours, stocké hashé (SHA-256) en DB, rotation à chaque usage
|
||||||
|
- **Rate limiting:** 100 requêtes/min par IP — header `X-RateLimit-Remaining`
|
||||||
|
- **CORS:** configuré pour `https://turf-ia.h3r7.tech` + localhost dev
|
||||||
|
- **Logs d'accès:** horodatés ISO 8601 dans `logs/saas_api.log`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Lancement
|
||||||
|
|
||||||
|
```bash
|
||||||
|
JWT_SECRET_KEY="votre_cle_secrete" \
|
||||||
|
CORS_ORIGINS="https://turf-ia.h3r7.tech" \
|
||||||
|
./venv/bin/python saas_api.py
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./venv/bin/pytest tests/test_auth.py -v
|
||||||
|
# Avec couverture:
|
||||||
|
./venv/bin/pytest tests/test_auth.py --cov=auth --cov=auth_db --cov=middleware --cov=saas_api --cov-report=term-missing
|
||||||
|
# Résultat: 27 tests OK, couverture globale 83%
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Structure des tables DB
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- users: id, email, password_hash, plan(free/premium/pro), created_at, is_active, daily_usage, last_usage_date
|
||||||
|
-- subscriptions: id, user_id, plan, start_date, end_date, stripe_customer_id
|
||||||
|
-- refresh_tokens: id, user_id, token_hash, created_at, expires_at, revoked
|
||||||
|
```
|
||||||
362
auth.py
Normal file
362
auth.py
Normal file
@@ -0,0 +1,362 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Auth Blueprint — JWT authentication + multi-tenant plan enforcement
|
||||||
|
Sprint 2-3: HRT-28
|
||||||
|
|
||||||
|
Endpoints:
|
||||||
|
POST /api/v1/auth/register — email/password registration
|
||||||
|
POST /api/v1/auth/login — returns access_token (15min) + refresh_token (30d)
|
||||||
|
POST /api/v1/auth/refresh — rotate refresh token, issue new access_token
|
||||||
|
POST /api/v1/auth/logout — revoke refresh token
|
||||||
|
|
||||||
|
Middleware exposed:
|
||||||
|
jwt_required_middleware() — decorator: valid access JWT required
|
||||||
|
plan_required(plans) — decorator: user plan must be in given list
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import hashlib
|
||||||
|
import secrets
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from functools import wraps
|
||||||
|
|
||||||
|
import bcrypt
|
||||||
|
from flask import Blueprint, request, jsonify, g, current_app
|
||||||
|
from flask_jwt_extended import (
|
||||||
|
JWTManager,
|
||||||
|
create_access_token,
|
||||||
|
create_refresh_token,
|
||||||
|
decode_token,
|
||||||
|
get_jwt_identity,
|
||||||
|
verify_jwt_in_request,
|
||||||
|
)
|
||||||
|
from flask_jwt_extended.exceptions import JWTExtendedException
|
||||||
|
from jwt.exceptions import PyJWTError
|
||||||
|
|
||||||
|
from auth_db import get_db
|
||||||
|
|
||||||
|
logger = logging.getLogger("turf_saas.auth")
|
||||||
|
|
||||||
|
auth_bp = Blueprint("auth", __name__, url_prefix="/api/v1/auth")
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# Helpers
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _hash_token(raw_token: str) -> str:
|
||||||
|
"""SHA-256 hash of a token string for secure DB storage."""
|
||||||
|
return hashlib.sha256(raw_token.encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _get_user_by_email(email: str):
|
||||||
|
db = get_db()
|
||||||
|
user = db.execute(
|
||||||
|
"SELECT * FROM users WHERE email = ? AND is_active = 1", (email.lower(),)
|
||||||
|
).fetchone()
|
||||||
|
db.close()
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def _get_user_by_id(user_id: int):
|
||||||
|
db = get_db()
|
||||||
|
user = db.execute(
|
||||||
|
"SELECT * FROM users WHERE id = ? AND is_active = 1", (user_id,)
|
||||||
|
).fetchone()
|
||||||
|
db.close()
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def _store_refresh_token(user_id: int, raw_token: str, expires_at: datetime):
|
||||||
|
token_hash = _hash_token(raw_token)
|
||||||
|
db = get_db()
|
||||||
|
db.execute(
|
||||||
|
"INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES (?,?,?)",
|
||||||
|
(user_id, token_hash, expires_at.isoformat()),
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _revoke_refresh_token(raw_token: str):
|
||||||
|
token_hash = _hash_token(raw_token)
|
||||||
|
db = get_db()
|
||||||
|
db.execute(
|
||||||
|
"UPDATE refresh_tokens SET revoked = 1 WHERE token_hash = ?", (token_hash,)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _is_refresh_token_valid(raw_token: str, user_id: int) -> bool:
|
||||||
|
token_hash = _hash_token(raw_token)
|
||||||
|
db = get_db()
|
||||||
|
row = db.execute(
|
||||||
|
"""SELECT id FROM refresh_tokens
|
||||||
|
WHERE token_hash = ? AND user_id = ? AND revoked = 0
|
||||||
|
AND expires_at > datetime('now')""",
|
||||||
|
(token_hash, user_id),
|
||||||
|
).fetchone()
|
||||||
|
db.close()
|
||||||
|
return row is not None
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# Auth endpoints
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@auth_bp.route("/register", methods=["POST"])
|
||||||
|
def register():
|
||||||
|
"""POST /api/v1/auth/register — create a new user account (plan=free)."""
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
email = (data.get("email") or "").strip().lower()
|
||||||
|
password = data.get("password") or ""
|
||||||
|
|
||||||
|
if not email or "@" not in email:
|
||||||
|
return jsonify({"error": "Email invalide"}), 400
|
||||||
|
if len(password) < 8:
|
||||||
|
return jsonify({"error": "Mot de passe trop court (min 8 caractères)"}), 400
|
||||||
|
|
||||||
|
# Check uniqueness
|
||||||
|
existing = _get_user_by_email(email)
|
||||||
|
if existing:
|
||||||
|
return jsonify({"error": "Email déjà enregistré"}), 409
|
||||||
|
|
||||||
|
password_hash = bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
|
||||||
|
|
||||||
|
db = get_db()
|
||||||
|
try:
|
||||||
|
cursor = db.execute(
|
||||||
|
"INSERT INTO users (email, password_hash, plan) VALUES (?,?,?)",
|
||||||
|
(email, password_hash, "free"),
|
||||||
|
)
|
||||||
|
user_id = cursor.lastrowid
|
||||||
|
# Create initial subscription record
|
||||||
|
db.execute(
|
||||||
|
"INSERT INTO subscriptions (user_id, plan) VALUES (?,?)",
|
||||||
|
(user_id, "free"),
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
logger.error("register error: %s", e)
|
||||||
|
return jsonify({"error": "Erreur interne"}), 500
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
logger.info("New user registered: %s (id=%s)", email, user_id)
|
||||||
|
return jsonify({"message": "Compte créé avec succès", "user_id": user_id}), 201
|
||||||
|
|
||||||
|
|
||||||
|
@auth_bp.route("/login", methods=["POST"])
|
||||||
|
def login():
|
||||||
|
"""POST /api/v1/auth/login — returns JWT access_token + refresh_token."""
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
email = (data.get("email") or "").strip().lower()
|
||||||
|
password = data.get("password") or ""
|
||||||
|
|
||||||
|
if not email or not password:
|
||||||
|
return jsonify({"error": "Email et mot de passe requis"}), 400
|
||||||
|
|
||||||
|
user = _get_user_by_email(email)
|
||||||
|
if not user:
|
||||||
|
return jsonify({"error": "Identifiants invalides"}), 401
|
||||||
|
|
||||||
|
if not bcrypt.checkpw(password.encode(), user["password_hash"].encode()):
|
||||||
|
logger.warning("Failed login attempt for %s", email)
|
||||||
|
return jsonify({"error": "Identifiants invalides"}), 401
|
||||||
|
|
||||||
|
# Create tokens
|
||||||
|
identity = str(user["id"])
|
||||||
|
additional_claims = {"plan": user["plan"], "email": user["email"]}
|
||||||
|
|
||||||
|
access_token = create_access_token(
|
||||||
|
identity=identity,
|
||||||
|
additional_claims=additional_claims,
|
||||||
|
)
|
||||||
|
raw_refresh = create_refresh_token(identity=identity)
|
||||||
|
|
||||||
|
refresh_expires = datetime.now(timezone.utc) + timedelta(days=30)
|
||||||
|
_store_refresh_token(user["id"], raw_refresh, refresh_expires)
|
||||||
|
|
||||||
|
logger.info("User %s logged in (plan=%s)", email, user["plan"])
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"access_token": access_token,
|
||||||
|
"refresh_token": raw_refresh,
|
||||||
|
"token_type": "Bearer",
|
||||||
|
"plan": user["plan"],
|
||||||
|
}
|
||||||
|
), 200
|
||||||
|
|
||||||
|
|
||||||
|
@auth_bp.route("/refresh", methods=["POST"])
|
||||||
|
def refresh():
|
||||||
|
"""POST /api/v1/auth/refresh — rotate refresh token, issue new access_token."""
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
raw_refresh = (data.get("refresh_token") or "").strip()
|
||||||
|
|
||||||
|
if not raw_refresh:
|
||||||
|
return jsonify({"error": "refresh_token manquant"}), 400
|
||||||
|
|
||||||
|
# Decode without verifying in DB first (to get user_id)
|
||||||
|
try:
|
||||||
|
decoded = decode_token(raw_refresh)
|
||||||
|
except Exception:
|
||||||
|
return jsonify({"error": "Refresh token invalide ou expiré"}), 401
|
||||||
|
|
||||||
|
user_id = int(decoded.get("sub", 0))
|
||||||
|
|
||||||
|
if not _is_refresh_token_valid(raw_refresh, user_id):
|
||||||
|
return jsonify({"error": "Refresh token invalide, révoqué ou expiré"}), 401
|
||||||
|
|
||||||
|
user = _get_user_by_id(user_id)
|
||||||
|
if not user:
|
||||||
|
return jsonify({"error": "Utilisateur introuvable"}), 401
|
||||||
|
|
||||||
|
# Revoke old refresh token (rotation)
|
||||||
|
_revoke_refresh_token(raw_refresh)
|
||||||
|
|
||||||
|
# Issue new tokens
|
||||||
|
identity = str(user["id"])
|
||||||
|
additional_claims = {"plan": user["plan"], "email": user["email"]}
|
||||||
|
new_access = create_access_token(
|
||||||
|
identity=identity, additional_claims=additional_claims
|
||||||
|
)
|
||||||
|
new_refresh = create_refresh_token(identity=identity)
|
||||||
|
|
||||||
|
refresh_expires = datetime.now(timezone.utc) + timedelta(days=30)
|
||||||
|
_store_refresh_token(user["id"], new_refresh, refresh_expires)
|
||||||
|
|
||||||
|
logger.info("Token refreshed for user_id=%s", user_id)
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"access_token": new_access,
|
||||||
|
"refresh_token": new_refresh,
|
||||||
|
"token_type": "Bearer",
|
||||||
|
"plan": user["plan"],
|
||||||
|
}
|
||||||
|
), 200
|
||||||
|
|
||||||
|
|
||||||
|
@auth_bp.route("/logout", methods=["POST"])
|
||||||
|
def logout():
|
||||||
|
"""POST /api/v1/auth/logout — revoke refresh token."""
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
raw_refresh = (data.get("refresh_token") or "").strip()
|
||||||
|
|
||||||
|
if raw_refresh:
|
||||||
|
_revoke_refresh_token(raw_refresh)
|
||||||
|
|
||||||
|
return jsonify({"message": "Déconnexion réussie"}), 200
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# JWT-protected middleware
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def jwt_required_middleware(fn):
|
||||||
|
"""Decorator: require a valid Bearer JWT access token."""
|
||||||
|
|
||||||
|
@wraps(fn)
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
try:
|
||||||
|
verify_jwt_in_request()
|
||||||
|
user_id = int(get_jwt_identity())
|
||||||
|
user = _get_user_by_id(user_id)
|
||||||
|
if not user:
|
||||||
|
return jsonify({"error": "Utilisateur introuvable"}), 401
|
||||||
|
g.current_user = dict(user)
|
||||||
|
g.current_user_id = user_id
|
||||||
|
except (JWTExtendedException, PyJWTError) as e:
|
||||||
|
logger.debug("JWT auth failed: %s", e)
|
||||||
|
return jsonify({"error": "Token invalide ou expiré", "detail": str(e)}), 401
|
||||||
|
return fn(*args, **kwargs)
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
def plan_required(*allowed_plans):
|
||||||
|
"""
|
||||||
|
Decorator factory: user's plan must be in allowed_plans.
|
||||||
|
Must be applied AFTER @jwt_required_middleware.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
@app.route("/api/v1/predictions")
|
||||||
|
@jwt_required_middleware
|
||||||
|
@plan_required("premium", "pro")
|
||||||
|
def premium_predictions():
|
||||||
|
...
|
||||||
|
"""
|
||||||
|
|
||||||
|
def decorator(fn):
|
||||||
|
@wraps(fn)
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
user = getattr(g, "current_user", None)
|
||||||
|
if not user:
|
||||||
|
return jsonify({"error": "Non authentifié"}), 401
|
||||||
|
if user["plan"] not in allowed_plans:
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"error": "Plan insuffisant",
|
||||||
|
"required": list(allowed_plans),
|
||||||
|
"current_plan": user["plan"],
|
||||||
|
"upgrade_url": "/api/v1/subscription/upgrade",
|
||||||
|
}
|
||||||
|
), 403
|
||||||
|
return fn(*args, **kwargs)
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
def free_daily_limit_check(fn):
|
||||||
|
"""
|
||||||
|
Decorator: enforce free plan daily limit (1 course/jour).
|
||||||
|
Must be applied AFTER @jwt_required_middleware.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@wraps(fn)
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
user = getattr(g, "current_user", None)
|
||||||
|
if not user or user["plan"] != "free":
|
||||||
|
return fn(*args, **kwargs)
|
||||||
|
|
||||||
|
today = datetime.now(timezone.utc).date().isoformat()
|
||||||
|
db = get_db()
|
||||||
|
row = db.execute(
|
||||||
|
"SELECT daily_usage, last_usage_date FROM users WHERE id = ?",
|
||||||
|
(user["id"],),
|
||||||
|
).fetchone()
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
if row and row["last_usage_date"] == today and row["daily_usage"] >= 1:
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"error": "Limite quotidienne atteinte (plan free: 1 course/jour)",
|
||||||
|
"upgrade_url": "/api/v1/subscription/upgrade",
|
||||||
|
}
|
||||||
|
), 429
|
||||||
|
|
||||||
|
# Increment usage
|
||||||
|
db = get_db()
|
||||||
|
if row and row["last_usage_date"] == today:
|
||||||
|
db.execute(
|
||||||
|
"UPDATE users SET daily_usage = daily_usage + 1 WHERE id = ?",
|
||||||
|
(user["id"],),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
db.execute(
|
||||||
|
"UPDATE users SET daily_usage = 1, last_usage_date = ? WHERE id = ?",
|
||||||
|
(today, user["id"]),
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
return fn(*args, **kwargs)
|
||||||
|
|
||||||
|
return wrapper
|
||||||
68
auth_db.py
Normal file
68
auth_db.py
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Auth DB — users and subscriptions schema for turf_saas.db
|
||||||
|
Sprint 2-3: Auth JWT + Multi-tenant (HRT-28)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import os
|
||||||
|
|
||||||
|
DB_PATH = os.environ.get("TURF_SAAS_DB", "/home/h3r7/turf_saas/turf_saas.db")
|
||||||
|
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def init_auth_tables():
|
||||||
|
"""Create users and subscriptions tables if they don't exist."""
|
||||||
|
conn = get_db()
|
||||||
|
c = conn.cursor()
|
||||||
|
|
||||||
|
c.executescript("""
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
email TEXT NOT NULL UNIQUE,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
plan TEXT NOT NULL DEFAULT 'free'
|
||||||
|
CHECK(plan IN ('free','premium','pro')),
|
||||||
|
created_at DATETIME NOT NULL DEFAULT (datetime('now')),
|
||||||
|
is_active INTEGER NOT NULL DEFAULT 1,
|
||||||
|
daily_usage INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_usage_date TEXT DEFAULT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS subscriptions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||||
|
plan TEXT NOT NULL CHECK(plan IN ('free','premium','pro')),
|
||||||
|
start_date DATETIME NOT NULL DEFAULT (datetime('now')),
|
||||||
|
end_date DATETIME,
|
||||||
|
stripe_customer_id TEXT,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||||
|
token_hash TEXT NOT NULL UNIQUE,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT (datetime('now')),
|
||||||
|
expires_at DATETIME NOT NULL,
|
||||||
|
revoked INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_subscriptions_user ON subscriptions(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_hash ON refresh_tokens(token_hash);
|
||||||
|
""")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
print("[auth_db] Tables users, subscriptions, refresh_tokens created/verified.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
init_auth_tables()
|
||||||
90
middleware.py
Normal file
90
middleware.py
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Middleware — rate limiting, CORS, and access logging
|
||||||
|
Sprint 2-3: HRT-28
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from collections import defaultdict
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from functools import wraps
|
||||||
|
from threading import Lock
|
||||||
|
|
||||||
|
from flask import request, jsonify, g
|
||||||
|
|
||||||
|
logger = logging.getLogger("turf_saas.middleware")
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# In-memory rate limiter (100 req/min per IP)
|
||||||
|
# For production: replace with Redis-backed counter
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_rate_store: dict = defaultdict(lambda: {"count": 0, "window_start": 0.0})
|
||||||
|
_rate_lock = Lock()
|
||||||
|
|
||||||
|
RATE_LIMIT = 100 # max requests
|
||||||
|
RATE_WINDOW = 60 # seconds
|
||||||
|
|
||||||
|
|
||||||
|
def rate_limit_middleware(app):
|
||||||
|
"""Register before_request rate limiting on the Flask app."""
|
||||||
|
|
||||||
|
@app.before_request
|
||||||
|
def check_rate_limit():
|
||||||
|
ip = request.remote_addr or "unknown"
|
||||||
|
now = time.time()
|
||||||
|
|
||||||
|
with _rate_lock:
|
||||||
|
bucket = _rate_store[ip]
|
||||||
|
if now - bucket["window_start"] >= RATE_WINDOW:
|
||||||
|
bucket["count"] = 0
|
||||||
|
bucket["window_start"] = now
|
||||||
|
bucket["count"] += 1
|
||||||
|
count = bucket["count"]
|
||||||
|
remaining = max(0, RATE_LIMIT - count)
|
||||||
|
|
||||||
|
if count > RATE_LIMIT:
|
||||||
|
logger.warning("Rate limit exceeded for IP %s", ip)
|
||||||
|
resp = jsonify({"error": "Trop de requêtes. Limite: 100/min par IP."})
|
||||||
|
resp.status_code = 429
|
||||||
|
resp.headers["X-RateLimit-Limit"] = str(RATE_LIMIT)
|
||||||
|
resp.headers["X-RateLimit-Remaining"] = "0"
|
||||||
|
resp.headers["Retry-After"] = str(RATE_WINDOW)
|
||||||
|
return resp
|
||||||
|
|
||||||
|
# Attach headers on all responses via after_request
|
||||||
|
g.rl_remaining = remaining
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# Access logs (timestamped)
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
access_log = logging.getLogger("turf_saas.access")
|
||||||
|
|
||||||
|
|
||||||
|
def access_log_middleware(app):
|
||||||
|
"""Register after_request access logging on the Flask app."""
|
||||||
|
|
||||||
|
@app.after_request
|
||||||
|
def log_access(response):
|
||||||
|
ip = request.remote_addr or "unknown"
|
||||||
|
ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
user_id = getattr(g, "current_user_id", "-")
|
||||||
|
access_log.info(
|
||||||
|
'%s %s %s "%s %s" %s %s',
|
||||||
|
ts,
|
||||||
|
ip,
|
||||||
|
user_id,
|
||||||
|
request.method,
|
||||||
|
request.path,
|
||||||
|
response.status_code,
|
||||||
|
response.content_length or 0,
|
||||||
|
)
|
||||||
|
# Attach rate-limit headers
|
||||||
|
remaining = getattr(g, "rl_remaining", None)
|
||||||
|
if remaining is not None:
|
||||||
|
response.headers["X-RateLimit-Limit"] = str(RATE_LIMIT)
|
||||||
|
response.headers["X-RateLimit-Remaining"] = str(remaining)
|
||||||
|
return response
|
||||||
247
saas_api.py
Normal file
247
saas_api.py
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Turf SaaS API v1 — Auth JWT + Multi-tenant
|
||||||
|
Sprint 2-3: HRT-28
|
||||||
|
|
||||||
|
Run:
|
||||||
|
FLASK_ENV=development ./venv/bin/python saas_api.py
|
||||||
|
|
||||||
|
Ports (isolated from production):
|
||||||
|
Portal: 8793
|
||||||
|
SaaS API: 8792 ← this file
|
||||||
|
Dashboard: 8791
|
||||||
|
Combined API: 8790
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
import logging.handlers
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from flask import Flask, jsonify, g, request
|
||||||
|
from flask_cors import CORS
|
||||||
|
from flask_jwt_extended import JWTManager, get_jwt
|
||||||
|
|
||||||
|
from auth_db import init_auth_tables
|
||||||
|
from auth import (
|
||||||
|
auth_bp,
|
||||||
|
jwt_required_middleware,
|
||||||
|
plan_required,
|
||||||
|
free_daily_limit_check,
|
||||||
|
_get_user_by_id,
|
||||||
|
)
|
||||||
|
from middleware import rate_limit_middleware, access_log_middleware
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# Logging setup
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
LOG_DIR = os.path.join(os.path.dirname(__file__), "logs")
|
||||||
|
os.makedirs(LOG_DIR, exist_ok=True)
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||||
|
handlers=[
|
||||||
|
logging.StreamHandler(sys.stdout),
|
||||||
|
logging.handlers.RotatingFileHandler(
|
||||||
|
os.path.join(LOG_DIR, "saas_api.log"),
|
||||||
|
maxBytes=5 * 1024 * 1024,
|
||||||
|
backupCount=3,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# App factory
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def create_app(test_config=None):
|
||||||
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
# JWT config
|
||||||
|
app.config["JWT_SECRET_KEY"] = os.environ.get(
|
||||||
|
"JWT_SECRET_KEY", "CHANGE_ME_IN_PRODUCTION_" + os.urandom(24).hex()
|
||||||
|
)
|
||||||
|
app.config["JWT_ACCESS_TOKEN_EXPIRES"] = 900 # 15 minutes
|
||||||
|
app.config["JWT_REFRESH_TOKEN_EXPIRES"] = 2592000 # 30 days
|
||||||
|
|
||||||
|
if test_config:
|
||||||
|
app.config.update(test_config)
|
||||||
|
|
||||||
|
# CORS — SaaS domain + localhost for dev
|
||||||
|
CORS(
|
||||||
|
app,
|
||||||
|
origins=os.environ.get(
|
||||||
|
"CORS_ORIGINS",
|
||||||
|
"http://localhost:8793,http://127.0.0.1:8793,https://turf-ia.h3r7.tech",
|
||||||
|
).split(","),
|
||||||
|
methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||||
|
allow_headers=["Content-Type", "Authorization"],
|
||||||
|
supports_credentials=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# JWT
|
||||||
|
jwt = JWTManager(app)
|
||||||
|
|
||||||
|
# ── JWT error handlers ────────────────────────────────────
|
||||||
|
@jwt.expired_token_loader
|
||||||
|
def expired_token(_jwt_header, _jwt_payload):
|
||||||
|
return jsonify({"error": "Token expiré"}), 401
|
||||||
|
|
||||||
|
@jwt.invalid_token_loader
|
||||||
|
def invalid_token(reason):
|
||||||
|
return jsonify({"error": "Token invalide", "detail": reason}), 422
|
||||||
|
|
||||||
|
@jwt.unauthorized_loader
|
||||||
|
def unauthorized(reason):
|
||||||
|
return jsonify({"error": "Token manquant ou invalide", "detail": reason}), 401
|
||||||
|
|
||||||
|
# ── Register middleware ───────────────────────────────────
|
||||||
|
rate_limit_middleware(app)
|
||||||
|
access_log_middleware(app)
|
||||||
|
|
||||||
|
# ── Blueprints ────────────────────────────────────────────
|
||||||
|
app.register_blueprint(auth_bp)
|
||||||
|
|
||||||
|
# ── Predictions routes (multi-tenant plan check) ──────────
|
||||||
|
|
||||||
|
@app.route("/api/v1/predictions", methods=["GET"])
|
||||||
|
@jwt_required_middleware
|
||||||
|
@free_daily_limit_check
|
||||||
|
def predictions():
|
||||||
|
"""
|
||||||
|
GET /api/v1/predictions
|
||||||
|
- free: Top 3 uniquement (déjà filtrées par le moteur ML)
|
||||||
|
- premium: toutes courses + alertes Telegram
|
||||||
|
- pro: API complète + export CSV disponible
|
||||||
|
"""
|
||||||
|
user = g.current_user
|
||||||
|
plan = user["plan"]
|
||||||
|
|
||||||
|
# Forward to combined_api for actual predictions
|
||||||
|
import requests as req
|
||||||
|
|
||||||
|
try:
|
||||||
|
params = dict(request.args)
|
||||||
|
resp = req.get(
|
||||||
|
"http://localhost:8790/api/predictions",
|
||||||
|
params=params,
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
data = resp.json()
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify(
|
||||||
|
{"error": "Service prédictions indisponible", "detail": str(e)}
|
||||||
|
), 503
|
||||||
|
|
||||||
|
# Plan filtering
|
||||||
|
if plan == "free":
|
||||||
|
# Top 3 only
|
||||||
|
if isinstance(data, list):
|
||||||
|
data = [
|
||||||
|
{k: v for k, v in p.items() if k not in ("score_detaille",)}
|
||||||
|
for p in data[:3]
|
||||||
|
]
|
||||||
|
return jsonify({"plan": plan, "predictions": data, "limit": "Top 3"}), 200
|
||||||
|
|
||||||
|
elif plan == "premium":
|
||||||
|
# All courses, but no CSV export
|
||||||
|
return jsonify(
|
||||||
|
{"plan": plan, "predictions": data, "telegram_alerts": True}
|
||||||
|
), 200
|
||||||
|
|
||||||
|
else: # pro
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"plan": plan,
|
||||||
|
"predictions": data,
|
||||||
|
"telegram_alerts": True,
|
||||||
|
"csv_export_url": "/api/v1/predictions/export",
|
||||||
|
}
|
||||||
|
), 200
|
||||||
|
|
||||||
|
@app.route("/api/v1/predictions/export", methods=["GET"])
|
||||||
|
@jwt_required_middleware
|
||||||
|
@plan_required("pro")
|
||||||
|
def predictions_export():
|
||||||
|
"""CSV export — pro plan only."""
|
||||||
|
import requests as req
|
||||||
|
import io
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = req.get(
|
||||||
|
"http://localhost:8790/api/predictions/export",
|
||||||
|
params=dict(request.args),
|
||||||
|
timeout=15,
|
||||||
|
)
|
||||||
|
from flask import Response
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
resp.content,
|
||||||
|
mimetype="text/csv",
|
||||||
|
headers={"Content-Disposition": "attachment; filename=predictions.csv"},
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": "Export indisponible", "detail": str(e)}), 503
|
||||||
|
|
||||||
|
@app.route("/api/v1/subscription/upgrade", methods=["GET"])
|
||||||
|
@jwt_required_middleware
|
||||||
|
def subscription_info():
|
||||||
|
"""Return available plans and current user plan."""
|
||||||
|
user = g.current_user
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"current_plan": user["plan"],
|
||||||
|
"plans": {
|
||||||
|
"free": {
|
||||||
|
"price": "0€/mois",
|
||||||
|
"features": ["Top 3 prédictions", "1 course/jour"],
|
||||||
|
},
|
||||||
|
"premium": {
|
||||||
|
"price": "9.99€/mois",
|
||||||
|
"features": [
|
||||||
|
"Toutes les courses",
|
||||||
|
"Alertes Telegram",
|
||||||
|
"Historique 30j",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"pro": {
|
||||||
|
"price": "29.99€/mois",
|
||||||
|
"features": [
|
||||||
|
"API complète",
|
||||||
|
"Export CSV",
|
||||||
|
"Alertes Telegram",
|
||||||
|
"Historique illimité",
|
||||||
|
"Support prioritaire",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"upgrade_contact": "contact@h3r7.tech",
|
||||||
|
}
|
||||||
|
), 200
|
||||||
|
|
||||||
|
# ── Health check ──────────────────────────────────────────
|
||||||
|
|
||||||
|
@app.route("/api/v1/health", methods=["GET"])
|
||||||
|
def health():
|
||||||
|
return jsonify(
|
||||||
|
{"status": "ok", "service": "turf-saas-api", "version": "2.3.0"}
|
||||||
|
), 200
|
||||||
|
|
||||||
|
# Init DB tables on startup
|
||||||
|
with app.app_context():
|
||||||
|
init_auth_tables()
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# Entrypoint
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app = create_app()
|
||||||
|
port = int(os.environ.get("SAAS_API_PORT", 8792))
|
||||||
|
app.run(host="0.0.0.0", port=port, debug=False)
|
||||||
404
tests/test_auth.py
Normal file
404
tests/test_auth.py
Normal file
@@ -0,0 +1,404 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Pytest tests — Auth JWT + Multi-tenant
|
||||||
|
Sprint 2-3: HRT-28
|
||||||
|
Coverage target: >= 80%
|
||||||
|
|
||||||
|
Run:
|
||||||
|
./venv/bin/pytest tests/test_auth.py -v --tb=short
|
||||||
|
./venv/bin/pytest tests/test_auth.py -v --cov=auth --cov=auth_db --cov=middleware --cov=saas_api --cov-report=term-missing
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import json
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
# Point to a temp SQLite DB for tests
|
||||||
|
_tmp_db = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
|
||||||
|
_tmp_db.close()
|
||||||
|
os.environ["TURF_SAAS_DB"] = _tmp_db.name
|
||||||
|
os.environ["JWT_SECRET_KEY"] = "test-secret-key-for-pytest"
|
||||||
|
|
||||||
|
# Add project root to path
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
||||||
|
|
||||||
|
from saas_api import create_app # noqa: E402
|
||||||
|
|
||||||
|
TEST_CONFIG = {
|
||||||
|
"TESTING": True,
|
||||||
|
"JWT_SECRET_KEY": "test-secret-key-for-pytest",
|
||||||
|
"JWT_ACCESS_TOKEN_EXPIRES": 900,
|
||||||
|
"JWT_REFRESH_TOKEN_EXPIRES": 2592000,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def app():
|
||||||
|
application = create_app(TEST_CONFIG)
|
||||||
|
yield application
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def client(app):
|
||||||
|
return app.test_client()
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# Health
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestHealth:
|
||||||
|
def test_health_ok(self, client):
|
||||||
|
resp = client.get("/api/v1/health")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.get_json()
|
||||||
|
assert data["status"] == "ok"
|
||||||
|
assert data["service"] == "turf-saas-api"
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# Registration
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestRegister:
|
||||||
|
def test_register_success(self, client):
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/auth/register",
|
||||||
|
json={"email": "user_test@example.com", "password": "password123"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
data = resp.get_json()
|
||||||
|
assert "user_id" in data
|
||||||
|
|
||||||
|
def test_register_duplicate(self, client):
|
||||||
|
client.post(
|
||||||
|
"/api/v1/auth/register",
|
||||||
|
json={"email": "dup@example.com", "password": "password123"},
|
||||||
|
)
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/auth/register",
|
||||||
|
json={"email": "dup@example.com", "password": "password123"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 409
|
||||||
|
|
||||||
|
def test_register_invalid_email(self, client):
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/auth/register",
|
||||||
|
json={"email": "notanemail", "password": "password123"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
def test_register_short_password(self, client):
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/auth/register",
|
||||||
|
json={"email": "shortpw@example.com", "password": "abc"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
def test_register_missing_fields(self, client):
|
||||||
|
resp = client.post("/api/v1/auth/register", json={})
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# Login
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestLogin:
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def create_user(self, client):
|
||||||
|
client.post(
|
||||||
|
"/api/v1/auth/register",
|
||||||
|
json={"email": "login@example.com", "password": "loginpass1"},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_login_success(self, client):
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/auth/login",
|
||||||
|
json={"email": "login@example.com", "password": "loginpass1"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.get_json()
|
||||||
|
assert "access_token" in data
|
||||||
|
assert "refresh_token" in data
|
||||||
|
assert data["plan"] == "free"
|
||||||
|
|
||||||
|
def test_login_wrong_password(self, client):
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/auth/login",
|
||||||
|
json={"email": "login@example.com", "password": "wrongpass"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
def test_login_unknown_email(self, client):
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/auth/login",
|
||||||
|
json={"email": "ghost@example.com", "password": "anypass"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
def test_login_missing_fields(self, client):
|
||||||
|
resp = client.post("/api/v1/auth/login", json={"email": "login@example.com"})
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# Token refresh
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestRefresh:
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def setup(self, client):
|
||||||
|
client.post(
|
||||||
|
"/api/v1/auth/register",
|
||||||
|
json={"email": "refresh@example.com", "password": "refreshpass1"},
|
||||||
|
)
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/auth/login",
|
||||||
|
json={"email": "refresh@example.com", "password": "refreshpass1"},
|
||||||
|
)
|
||||||
|
tokens = resp.get_json()
|
||||||
|
self.refresh_token = tokens["refresh_token"]
|
||||||
|
|
||||||
|
def test_refresh_success(self, client):
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/auth/refresh",
|
||||||
|
json={"refresh_token": self.refresh_token},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.get_json()
|
||||||
|
assert "access_token" in data
|
||||||
|
assert "refresh_token" in data
|
||||||
|
# New refresh token should differ from old
|
||||||
|
assert data["refresh_token"] != self.refresh_token
|
||||||
|
|
||||||
|
def test_refresh_token_rotation(self, client):
|
||||||
|
"""Old refresh token must be invalid after rotation."""
|
||||||
|
client.post(
|
||||||
|
"/api/v1/auth/refresh",
|
||||||
|
json={"refresh_token": self.refresh_token},
|
||||||
|
)
|
||||||
|
resp2 = client.post(
|
||||||
|
"/api/v1/auth/refresh",
|
||||||
|
json={"refresh_token": self.refresh_token},
|
||||||
|
)
|
||||||
|
assert resp2.status_code == 401
|
||||||
|
|
||||||
|
def test_refresh_invalid_token(self, client):
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/auth/refresh",
|
||||||
|
json={"refresh_token": "completely.invalid.token"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
def test_refresh_missing_token(self, client):
|
||||||
|
resp = client.post("/api/v1/auth/refresh", json={})
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# Logout
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestLogout:
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def setup(self, client):
|
||||||
|
client.post(
|
||||||
|
"/api/v1/auth/register",
|
||||||
|
json={"email": "logout@example.com", "password": "logoutpass1"},
|
||||||
|
)
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/auth/login",
|
||||||
|
json={"email": "logout@example.com", "password": "logoutpass1"},
|
||||||
|
)
|
||||||
|
tokens = resp.get_json()
|
||||||
|
self.refresh_token = tokens["refresh_token"]
|
||||||
|
self.access_token = tokens["access_token"]
|
||||||
|
|
||||||
|
def test_logout_success(self, client):
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/auth/logout",
|
||||||
|
json={"refresh_token": self.refresh_token},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
def test_refresh_after_logout_fails(self, client):
|
||||||
|
client.post("/api/v1/auth/logout", json={"refresh_token": self.refresh_token})
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/auth/refresh",
|
||||||
|
json={"refresh_token": self.refresh_token},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
def test_logout_no_token(self, client):
|
||||||
|
resp = client.post("/api/v1/auth/logout", json={})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# JWT middleware — protected routes
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestJWTMiddleware:
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def setup(self, client):
|
||||||
|
client.post(
|
||||||
|
"/api/v1/auth/register",
|
||||||
|
json={"email": "protected@example.com", "password": "protect123"},
|
||||||
|
)
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/auth/login",
|
||||||
|
json={"email": "protected@example.com", "password": "protect123"},
|
||||||
|
)
|
||||||
|
self.access_token = resp.get_json()["access_token"]
|
||||||
|
|
||||||
|
def test_subscription_info_requires_auth(self, client):
|
||||||
|
resp = client.get("/api/v1/subscription/upgrade")
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
def test_subscription_info_with_token(self, client):
|
||||||
|
resp = client.get(
|
||||||
|
"/api/v1/subscription/upgrade",
|
||||||
|
headers={"Authorization": f"Bearer {self.access_token}"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.get_json()
|
||||||
|
assert "current_plan" in data
|
||||||
|
assert data["current_plan"] == "free"
|
||||||
|
|
||||||
|
def test_invalid_token_rejected(self, client):
|
||||||
|
resp = client.get(
|
||||||
|
"/api/v1/subscription/upgrade",
|
||||||
|
headers={"Authorization": "Bearer invalid.token.here"},
|
||||||
|
)
|
||||||
|
assert resp.status_code in (401, 422)
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# Plan checks
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestPlanMiddleware:
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def setup(self, client, app):
|
||||||
|
# Register free user
|
||||||
|
client.post(
|
||||||
|
"/api/v1/auth/register",
|
||||||
|
json={"email": "free_plan@example.com", "password": "freepass1"},
|
||||||
|
)
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/auth/login",
|
||||||
|
json={"email": "free_plan@example.com", "password": "freepass1"},
|
||||||
|
)
|
||||||
|
self.free_token = resp.get_json()["access_token"]
|
||||||
|
|
||||||
|
# Upgrade user to pro directly in DB for testing
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
db_path = os.environ["TURF_SAAS_DB"]
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO users (email, password_hash, plan) VALUES (?,?,?)",
|
||||||
|
("pro_plan@example.com", "$2b$12$placeholder", "pro"),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# Login pro user using JWT created manually via app context
|
||||||
|
with app.app_context():
|
||||||
|
from flask_jwt_extended import create_access_token
|
||||||
|
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT id FROM users WHERE email='pro_plan@example.com'"
|
||||||
|
).fetchone()
|
||||||
|
conn.close()
|
||||||
|
self.pro_token = create_access_token(
|
||||||
|
identity=str(row[0]),
|
||||||
|
additional_claims={"plan": "pro", "email": "pro_plan@example.com"},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_export_blocked_for_free(self, client):
|
||||||
|
resp = client.get(
|
||||||
|
"/api/v1/predictions/export",
|
||||||
|
headers={"Authorization": f"Bearer {self.free_token}"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
data = resp.get_json()
|
||||||
|
assert "Plan insuffisant" in data["error"]
|
||||||
|
|
||||||
|
def test_export_allowed_for_pro(self, client):
|
||||||
|
resp = client.get(
|
||||||
|
"/api/v1/predictions/export",
|
||||||
|
headers={"Authorization": f"Bearer {self.pro_token}"},
|
||||||
|
)
|
||||||
|
# 503 is expected because no backend is running; 403 would be wrong
|
||||||
|
assert resp.status_code != 403
|
||||||
|
|
||||||
|
def test_upgrade_info_shows_plans(self, client):
|
||||||
|
resp = client.get(
|
||||||
|
"/api/v1/subscription/upgrade",
|
||||||
|
headers={"Authorization": f"Bearer {self.free_token}"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.get_json()
|
||||||
|
assert "free" in data["plans"]
|
||||||
|
assert "premium" in data["plans"]
|
||||||
|
assert "pro" in data["plans"]
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# Rate limiting
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestRateLimiting:
|
||||||
|
def test_rate_limit_headers_present(self, client):
|
||||||
|
resp = client.get("/api/v1/health")
|
||||||
|
assert "X-RateLimit-Limit" in resp.headers
|
||||||
|
assert resp.headers["X-RateLimit-Limit"] == "100"
|
||||||
|
|
||||||
|
def test_rate_limit_remaining_decreases(self, client):
|
||||||
|
r1 = client.get("/api/v1/health")
|
||||||
|
r2 = client.get("/api/v1/health")
|
||||||
|
rem1 = int(r1.headers.get("X-RateLimit-Remaining", 100))
|
||||||
|
rem2 = int(r2.headers.get("X-RateLimit-Remaining", 100))
|
||||||
|
assert rem2 <= rem1
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# DB module
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestAuthDB:
|
||||||
|
def test_tables_exist(self):
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
conn = sqlite3.connect(os.environ["TURF_SAAS_DB"])
|
||||||
|
tables = {
|
||||||
|
r[0]
|
||||||
|
for r in conn.execute(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table'"
|
||||||
|
).fetchall()
|
||||||
|
}
|
||||||
|
assert "users" in tables
|
||||||
|
assert "subscriptions" in tables
|
||||||
|
assert "refresh_tokens" in tables
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def test_get_db_returns_connection(self):
|
||||||
|
from auth_db import get_db
|
||||||
|
|
||||||
|
db = get_db()
|
||||||
|
assert db is not None
|
||||||
|
db.close()
|
||||||
Reference in New Issue
Block a user