From f300e44c7467248b88de547b148baf27a646b2b2 Mon Sep 17 00:00:00 2001 From: DevOps Engineer Date: Wed, 29 Apr 2026 17:25:30 +0200 Subject: [PATCH] feat(HRT-80): API Token personnel + Webhook alertes (Pro) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Nouveaux fichiers: api_tokens_db.py, api_v1/routes/user_tokens.py, api_v1/utils_webhook.py - Migration DB idempotente: tables user_api_tokens + user_webhooks - Endpoints POST/DELETE /api/v1/user/api-token (Pro only) - Endpoints POST/DELETE /api/v1/user/webhook (Pro only, HTTPS requis) - HMAC-SHA256 fire-and-forget dispatch webhook - auth.py: validate_api_key() + X-API-Key fallback dans jwt_required_middleware - saas_auth.py: import logging au niveau module, validate_api_key(), X-API-Key fallback - api_v1/__init__.py: enregistrement user_tokens_bp - 24 tests pytest — tous passent Co-Authored-By: Paperclip --- api_tokens_db.py | 57 ++++++ api_v1/__init__.py | 5 + api_v1/routes/user_tokens.py | 195 ++++++++++++++++++ api_v1/utils_webhook.py | 80 ++++++++ auth.py | 52 ++++- saas_auth.py | 43 +++- tests/test_user_tokens.py | 383 +++++++++++++++++++++++++++++++++++ 7 files changed, 811 insertions(+), 4 deletions(-) create mode 100644 api_tokens_db.py create mode 100644 api_v1/routes/user_tokens.py create mode 100644 api_v1/utils_webhook.py create mode 100644 tests/test_user_tokens.py diff --git a/api_tokens_db.py b/api_tokens_db.py new file mode 100644 index 0000000..ee2b732 --- /dev/null +++ b/api_tokens_db.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +""" +api_tokens_db.py — DB migration for personal API tokens + user webhooks +HRT-80: API Token personnel + Webhook alertes (Pro) +""" + +import logging +import os +import sqlite3 + +DB_PATH = os.environ.get("TURF_SAAS_DB", "/home/h3r7/turf_saas/turf_saas.db") +logger = logging.getLogger("turf_saas.api_tokens_db") + + +def get_db() -> sqlite3.Connection: + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + return conn + + +def migrate_api_tokens_tables() -> None: + """Idempotent migration: create user_api_tokens and user_webhooks.""" + conn = get_db() + c = conn.cursor() + c.executescript(""" + CREATE TABLE IF NOT EXISTS user_api_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + token_hash TEXT NOT NULL UNIQUE, + token_prefix TEXT NOT NULL, + created_at DATETIME NOT NULL DEFAULT (datetime('now')), + last_used_at DATETIME, + revoked INTEGER NOT NULL DEFAULT 0 + ); + CREATE INDEX IF NOT EXISTS idx_api_tokens_user ON user_api_tokens(user_id); + CREATE INDEX IF NOT EXISTS idx_api_tokens_hash ON user_api_tokens(token_hash); + + CREATE TABLE IF NOT EXISTS user_webhooks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL UNIQUE, + url TEXT NOT NULL, + secret TEXT NOT NULL, + created_at DATETIME NOT NULL DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_webhooks_user ON user_webhooks(user_id); + """) + conn.commit() + conn.close() + logger.info( + "[api_tokens_db] Tables user_api_tokens + user_webhooks created/verified." + ) + + +if __name__ == "__main__": + logging.basicConfig(level=logging.INFO) + migrate_api_tokens_tables() + print("[api_tokens_db] Migration complete.") diff --git a/api_v1/__init__.py b/api_v1/__init__.py index 813b59a..a65f0b9 100644 --- a/api_v1/__init__.py +++ b/api_v1/__init__.py @@ -4,6 +4,7 @@ API v1 Blueprint package — Turf SaaS Sprint 3-4: HRT-29 — Refacto API /v1/ Sprint 5-6: HRT-31 — Billing Stripe HRT-79: Alertes Telegram configurables (user blueprint) +HRT-80: API Token personnel + Webhook alertes (Pro) Registers sub-blueprints: /api/v1/health — public health-check @@ -15,6 +16,8 @@ Registers sub-blueprints: /api/v1/metrics — métriques perf ML (premium+) /api/v1/billing/ — Stripe checkout, portal, webhook, status /api/v1/user/ — config utilisateur, alertes Telegram (premium+) + /api/v1/user/api-token — Personal API token (Pro) + /api/v1/user/webhook — Webhook config (Pro) /api/v1/history — historique préd. ML (Free:7j, Premium:90j, Pro:illimité) /api/v1/docs — Swagger UI (via flasgger, registered on app) """ @@ -30,6 +33,7 @@ from .routes.export import export_bp from .routes.metrics import metrics_bp from .routes.billing import billing_bp from .routes.user import user_bp +from .routes.user_tokens import user_tokens_bp from .routes.history import history_bp # Master blueprint that aggregates all sub-routes under /api/v1 @@ -47,4 +51,5 @@ def register_api_v1(app): app.register_blueprint(metrics_bp) app.register_blueprint(billing_bp) app.register_blueprint(user_bp) + app.register_blueprint(user_tokens_bp) app.register_blueprint(history_bp) diff --git a/api_v1/routes/user_tokens.py b/api_v1/routes/user_tokens.py new file mode 100644 index 0000000..b8dab0c --- /dev/null +++ b/api_v1/routes/user_tokens.py @@ -0,0 +1,195 @@ +#!/usr/bin/env python3 +""" +user_tokens.py — Personal API tokens + Webhook configuration (Pro plan) +HRT-80 + +Endpoints: + POST /api/v1/user/api-token + DELETE /api/v1/user/api-token + POST /api/v1/user/webhook + DELETE /api/v1/user/webhook +""" + +import hashlib +import logging +import secrets + +from flask import Blueprint, g, jsonify, request + +from api_tokens_db import get_db, migrate_api_tokens_tables +from auth import jwt_required_middleware, plan_required + +logger = logging.getLogger("turf_saas.user_tokens") + +user_tokens_bp = Blueprint("user_tokens", __name__, url_prefix="/api/v1/user") + +try: + migrate_api_tokens_tables() +except Exception as _e: + logger.warning("api_tokens_db migration skipped (test env?): %s", _e) + + +def _hash_token(raw: str) -> str: + return hashlib.sha256(raw.encode()).hexdigest() + + +@user_tokens_bp.route("/api-token", methods=["POST"]) +@jwt_required_middleware +@plan_required("pro") +def create_api_token(): + user = g.current_user + user_id = str(user["id"]) + conn = get_db() + try: + existing = conn.execute( + "SELECT id, token_prefix, created_at FROM user_api_tokens " + "WHERE user_id = ? AND revoked = 0", + (user_id,), + ).fetchone() + if existing: + return jsonify( + { + "error": "Un token actif existe déjà. Révoquez-le avant d'en créer un nouveau.", + "existing_prefix": existing["token_prefix"], + "created_at": existing["created_at"], + } + ), 409 + + raw_token = "trf_" + secrets.token_urlsafe(40) + token_hash = _hash_token(raw_token) + token_prefix = raw_token[:12] + + conn.execute( + "INSERT INTO user_api_tokens (user_id, token_hash, token_prefix) VALUES (?, ?, ?)", + (user_id, token_hash, token_prefix), + ) + conn.commit() + + row = conn.execute( + "SELECT created_at FROM user_api_tokens WHERE token_hash = ?", + (token_hash,), + ).fetchone() + created_at = row["created_at"] if row else None + except Exception as e: + conn.rollback() + logger.error("create_api_token error for user %s: %s", user_id, e) + return jsonify({"error": "Erreur interne"}), 500 + finally: + conn.close() + + logger.info("API token created for user %s (prefix=%s)", user_id, token_prefix) + return jsonify( + { + "token": raw_token, + "prefix": token_prefix, + "created_at": created_at, + "warning": "Conservez ce token en lieu sûr. Il ne sera plus affiché.", + } + ), 201 + + +@user_tokens_bp.route("/api-token", methods=["DELETE"]) +@jwt_required_middleware +@plan_required("pro") +def revoke_api_token(): + user = g.current_user + user_id = str(user["id"]) + conn = get_db() + try: + result = conn.execute( + "UPDATE user_api_tokens SET revoked = 1 WHERE user_id = ? AND revoked = 0", + (user_id,), + ) + conn.commit() + revoked_count = result.rowcount + except Exception as e: + conn.rollback() + logger.error("revoke_api_token error for user %s: %s", user_id, e) + return jsonify({"error": "Erreur interne"}), 500 + finally: + conn.close() + + if revoked_count == 0: + return jsonify({"error": "Aucun token actif trouvé"}), 404 + + logger.info("API token(s) revoked for user %s (%d tokens)", user_id, revoked_count) + return jsonify({"revoked": True, "count": revoked_count}), 200 + + +@user_tokens_bp.route("/webhook", methods=["POST"]) +@jwt_required_middleware +@plan_required("pro") +def create_webhook(): + user = g.current_user + user_id = str(user["id"]) + data = request.get_json(silent=True) or {} + url = (data.get("url") or "").strip() + + if not url: + return jsonify({"error": "URL du webhook manquante"}), 400 + if not url.startswith("https://"): + return jsonify( + {"error": "L'URL du webhook doit utiliser HTTPS (commencer par https://)"} + ), 400 + + secret = (data.get("secret") or "").strip() or secrets.token_hex(32) + + conn = get_db() + existing = None + try: + existing = conn.execute( + "SELECT id FROM user_webhooks WHERE user_id = ?", (user_id,) + ).fetchone() + if existing: + conn.execute( + "UPDATE user_webhooks SET url = ?, secret = ?, created_at = datetime('now') " + "WHERE user_id = ?", + (url, secret, user_id), + ) + else: + conn.execute( + "INSERT INTO user_webhooks (user_id, url, secret) VALUES (?, ?, ?)", + (user_id, url, secret), + ) + conn.commit() + except Exception as e: + conn.rollback() + logger.error("create_webhook error for user %s: %s", user_id, e) + return jsonify({"error": "Erreur interne"}), 500 + finally: + conn.close() + + action = "mis à jour" if existing else "configuré" + logger.info("Webhook %s for user %s: %s", action, user_id, url) + return jsonify( + { + "webhook_url": url, + "secret": secret, + "message": f"Webhook {action} avec succès", + } + ), 201 + + +@user_tokens_bp.route("/webhook", methods=["DELETE"]) +@jwt_required_middleware +@plan_required("pro") +def delete_webhook(): + user = g.current_user + user_id = str(user["id"]) + conn = get_db() + try: + result = conn.execute("DELETE FROM user_webhooks WHERE user_id = ?", (user_id,)) + conn.commit() + deleted_count = result.rowcount + except Exception as e: + conn.rollback() + logger.error("delete_webhook error for user %s: %s", user_id, e) + return jsonify({"error": "Erreur interne"}), 500 + finally: + conn.close() + + if deleted_count == 0: + return jsonify({"error": "Aucun webhook configuré"}), 404 + + logger.info("Webhook deleted for user %s", user_id) + return jsonify({"deleted": True}), 200 diff --git a/api_v1/utils_webhook.py b/api_v1/utils_webhook.py new file mode 100644 index 0000000..ea66e9a --- /dev/null +++ b/api_v1/utils_webhook.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +""" +utils_webhook.py — Webhook dispatch utility (fire-and-forget, HMAC-SHA256) +HRT-80 +""" + +import hashlib +import hmac +import json +import logging + +import requests + +from api_tokens_db import get_db + +logger = logging.getLogger("turf_saas.webhook") + +EVENT_NEW_PREDICTION = "new_prediction" +EVENT_VALUE_BET = "value_bet" + + +def dispatch_webhook(user_id: str, event_type: str, payload: dict) -> None: + """ + Send HMAC-signed webhook POST to URL configured by user. + Fire-and-forget: errors logged, never re-raised. Timeout: 5s. + """ + conn = get_db() + try: + row = conn.execute( + "SELECT url, secret FROM user_webhooks WHERE user_id = ?", + (str(user_id),), + ).fetchone() + except Exception as e: + logger.warning("dispatch_webhook: DB error for user %s: %s", user_id, e) + return + finally: + conn.close() + + if not row: + return + + url = row["url"] + secret = row["secret"] + body = json.dumps( + {"event": event_type, "data": payload}, + ensure_ascii=False, + separators=(",", ":"), + ) + signature = hmac.new( + secret.encode("utf-8"), body.encode("utf-8"), hashlib.sha256 + ).hexdigest() + headers = { + "Content-Type": "application/json", + "X-Turf-Signature": f"sha256={signature}", + "X-Turf-Event": event_type, + "User-Agent": "TurfSaaS-Webhook/1.0", + } + try: + resp = requests.post(url, data=body, headers=headers, timeout=5) + logger.info( + "Webhook dispatched to user %s (event=%s, status=%s)", + user_id, + event_type, + resp.status_code, + ) + except requests.exceptions.Timeout: + logger.warning( + "Webhook timeout for user %s (event=%s, url=%s)", user_id, event_type, url + ) + except requests.exceptions.RequestException as e: + logger.warning( + "Webhook failed for user %s (event=%s): %s", user_id, event_type, e + ) + except Exception as e: + logger.warning( + "Webhook unexpected error for user %s (event=%s): %s", + user_id, + event_type, + e, + ) diff --git a/auth.py b/auth.py index b2bfb58..ef4d2ac 100644 --- a/auth.py +++ b/auth.py @@ -258,11 +258,47 @@ def logout(): # ────────────────────────────────────────────────────────────── +def validate_api_key(raw_key: str): + """ + Validate a personal API token (X-API-Key header). + Returns user dict or None. Updates last_used_at on success. + HRT-80: Personal API token support. + """ + if not raw_key: + return None + key_hash = hashlib.sha256(raw_key.encode()).hexdigest() + db = get_db() + try: + row = db.execute( + "SELECT t.user_id, u.* FROM user_api_tokens t " + "JOIN users u ON CAST(t.user_id AS INTEGER) = u.id " + "WHERE t.token_hash = ? AND t.revoked = 0 AND u.is_active = 1", + (key_hash,), + ).fetchone() + if row: + db.execute( + "UPDATE user_api_tokens SET last_used_at = datetime('now') " + "WHERE token_hash = ?", + (key_hash,), + ) + db.commit() + return dict(row) if row else None + except Exception as e: + logger.warning("validate_api_key error: %s", e) + return None + finally: + db.close() + + def jwt_required_middleware(fn): - """Decorator: require a valid Bearer JWT access token.""" + """ + Decorator: require a valid Bearer JWT access token OR X-API-Key personal token. + HRT-80: Added X-API-Key fallback for personal API tokens (Pro plan only). + """ @wraps(fn) def wrapper(*args, **kwargs): + # 1. Try Bearer JWT (existing flow — unchanged) try: verify_jwt_in_request() user_id = int(get_jwt_identity()) @@ -271,10 +307,20 @@ def jwt_required_middleware(fn): return jsonify({"error": "Utilisateur introuvable"}), 401 g.current_user = dict(user) g.current_user_id = user_id + return fn(*args, **kwargs) 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) + + # 2. Fallback: X-API-Key personal token (HRT-80) + api_key = request.headers.get("X-API-Key", "").strip() + if api_key: + user = validate_api_key(api_key) + if user: + g.current_user = user + g.current_user_id = user.get("id") + return fn(*args, **kwargs) + + return jsonify({"error": "Token invalide ou expiré"}), 401 return wrapper diff --git a/saas_auth.py b/saas_auth.py index 2047373..1725c95 100644 --- a/saas_auth.py +++ b/saas_auth.py @@ -8,6 +8,7 @@ Sprint 4-5 — HRT-30 from flask import Blueprint, request, jsonify, current_app import sqlite3 import hashlib +import logging import secrets import os import time @@ -229,14 +230,54 @@ def hash_password(password: str) -> str: return hashlib.sha256(password.encode("utf-8")).hexdigest() +def validate_api_key(raw_key: str): + """ + Validate a personal API token (X-API-Key header). + Returns user dict or None. Updates last_used_at on success. + HRT-80 + """ + if not raw_key: + return None + key_hash = hashlib.sha256(raw_key.encode()).hexdigest() + conn = get_db() + try: + row = conn.execute( + "SELECT t.user_id, u.* FROM user_api_tokens t " + "JOIN saas_users u ON t.user_id = u.id " + "WHERE t.token_hash = ? AND t.revoked = 0", + (key_hash,), + ).fetchone() + if row: + conn.execute( + "UPDATE user_api_tokens SET last_used_at = datetime('now') " + "WHERE token_hash = ?", + (key_hash,), + ) + conn.commit() + return dict(row) if row else None + except Exception as e: + logging.getLogger("turf_saas.auth").warning("validate_api_key error: %s", e) + return None + finally: + conn.close() + + def require_auth(f): @wraps(f) def decorated(*args, **kwargs): + # 1. Try Bearer session token (existing flow — unchanged) auth = request.headers.get("Authorization", "") token = ( auth.removeprefix("Bearer ").strip() if auth.startswith("Bearer ") else None ) - user = validate_token(token) + user = validate_token(token) if token else None + + # 2. Fallback: X-API-Key personal token (HRT-80) + if not user: + api_key = request.headers.get("X-API-Key", "").strip() + if api_key: + user = validate_api_key(api_key) + if not user: return jsonify({"error": "Non authentifié"}), 401 request.current_user = user diff --git a/tests/test_user_tokens.py b/tests/test_user_tokens.py new file mode 100644 index 0000000..8b444ff --- /dev/null +++ b/tests/test_user_tokens.py @@ -0,0 +1,383 @@ +#!/usr/bin/env python3 +""" +tests/test_user_tokens.py — Personal API Token + Webhook alertes +HRT-80: Tests unitaires et d'intégration + +Couvre: + - POST /api/v1/user/api-token (create) + - DELETE /api/v1/user/api-token (revoke) + - POST /api/v1/user/webhook (create/upsert) + - DELETE /api/v1/user/webhook (delete) + - Authentification via X-API-Key + - dispatch_webhook() fire-and-forget + - Plan enforcement Pro uniquement + +Run: + ./venv/bin/pytest tests/test_user_tokens.py -v --tb=short +""" + +import hashlib +import json +import os +import sqlite3 +import sys +import tempfile +from unittest.mock import MagicMock, patch + +import pytest + +# ─── Test DB isolation ──────────────────────────────────────────────────────── +_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-hrt80" + +# Add project root to path +sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) + +from app_v1 import create_app # noqa: E402 + +TEST_CONFIG = { + "TESTING": True, + "JWT_SECRET_KEY": "test-secret-hrt80", +} + + +@pytest.fixture(scope="module") +def app(): + application = create_app() + application.config.update(TEST_CONFIG) + yield application + + +@pytest.fixture(scope="module") +def client(app): + return app.test_client() + + +# ─── Helpers ───────────────────────────────────────────────────────────────── + + +def _create_user(client, email, plan="pro"): + """Register user (plan=free) then update plan in DB.""" + resp = client.post( + "/api/v1/auth/register", + json={"email": email, "password": "Secure123"}, + ) + assert resp.status_code == 201, resp.get_json() + user_id = resp.get_json()["user_id"] + + # Update plan directly in DB (no plan-update endpoint in JWT auth) + conn = sqlite3.connect(os.environ["TURF_SAAS_DB"]) + conn.execute("UPDATE users SET plan = ? WHERE id = ?", (plan, user_id)) + conn.commit() + conn.close() + + # Login to get access token + login_resp = client.post( + "/api/v1/auth/login", + json={"email": email, "password": "Secure123"}, + ) + assert login_resp.status_code == 200, login_resp.get_json() + access_token = login_resp.get_json()["access_token"] + return access_token, user_id + + +def _auth_header(token): + return {"Authorization": f"Bearer {token}"} + + +# ─── Tests: API Token (Pro) ─────────────────────────────────────────────────── + + +class TestApiToken: + def test_create_api_token_pro(self, client): + """POST /api/v1/user/api-token — Pro user gets 201 + token starting with trf_""" + token, _ = _create_user(client, "pro_token@test.com", plan="pro") + resp = client.post("/api/v1/user/api-token", headers=_auth_header(token)) + assert resp.status_code == 201, resp.get_json() + data = resp.get_json() + assert data["token"].startswith("trf_") + assert data["prefix"] == data["token"][:12] + assert "warning" in data + assert "created_at" in data + + def test_create_api_token_stores_hash_not_raw(self, client): + """Second POST returns 409 — only hashed token stored""" + token, _ = _create_user(client, "pro_token2@test.com", plan="pro") + # First create + r1 = client.post("/api/v1/user/api-token", headers=_auth_header(token)) + assert r1.status_code == 201 + raw_token = r1.get_json()["token"] + # Second create should conflict + r2 = client.post("/api/v1/user/api-token", headers=_auth_header(token)) + assert r2.status_code == 409 + data = r2.get_json() + assert "existing_prefix" in data + # Verify raw token is NOT stored in DB (only hash) + conn = sqlite3.connect(os.environ["TURF_SAAS_DB"]) + row = conn.execute( + "SELECT token_hash FROM user_api_tokens WHERE token_prefix = ?", + (raw_token[:12],), + ).fetchone() + conn.close() + assert row is not None + assert row[0] != raw_token # hash != raw + assert len(row[0]) == 64 # SHA256 hex + + def test_create_api_token_free_user(self, client): + """Free user gets 403""" + token, _ = _create_user(client, "free_token@test.com", plan="free") + resp = client.post("/api/v1/user/api-token", headers=_auth_header(token)) + assert resp.status_code == 403 + + def test_create_api_token_premium_user(self, client): + """Premium user gets 403 (Pro only feature)""" + token, _ = _create_user(client, "premium_token@test.com", plan="premium") + resp = client.post("/api/v1/user/api-token", headers=_auth_header(token)) + assert resp.status_code == 403 + + def test_create_api_token_no_auth(self, client): + """No auth → 401""" + resp = client.post("/api/v1/user/api-token") + assert resp.status_code == 401 + + def test_revoke_api_token(self, client): + """DELETE /api/v1/user/api-token — Pro user revokes active token""" + token, _ = _create_user(client, "pro_revoke@test.com", plan="pro") + # Create first + client.post("/api/v1/user/api-token", headers=_auth_header(token)) + # Revoke + resp = client.delete("/api/v1/user/api-token", headers=_auth_header(token)) + assert resp.status_code == 200 + data = resp.get_json() + assert data["revoked"] is True + assert data["count"] >= 1 + + def test_revoke_no_active_token(self, client): + """DELETE with no active token → 404""" + token, _ = _create_user(client, "pro_notoken@test.com", plan="pro") + resp = client.delete("/api/v1/user/api-token", headers=_auth_header(token)) + assert resp.status_code == 404 + + def test_revoke_non_pro(self, client): + """DELETE for free user → 403""" + token, _ = _create_user(client, "free_revoke@test.com", plan="free") + resp = client.delete("/api/v1/user/api-token", headers=_auth_header(token)) + assert resp.status_code == 403 + + +# ─── Tests: X-API-Key Authentication ───────────────────────────────────────── + + +class TestApiKeyAuth: + def test_api_key_auth_on_protected_route(self, client): + """Valid X-API-Key authenticates on protected route""" + token, _ = _create_user(client, "apikey_auth@test.com", plan="pro") + # Create API token + r = client.post("/api/v1/user/api-token", headers=_auth_header(token)) + assert r.status_code == 201 + raw_key = r.get_json()["token"] + # Use X-API-Key to access a protected route (try create again → 409 means authenticated) + resp = client.post("/api/v1/user/api-token", headers={"X-API-Key": raw_key}) + # 409 means we were authenticated; 401 means auth failed + assert resp.status_code == 409 + + def test_api_key_invalid(self, client): + """Invalid X-API-Key → 401""" + resp = client.post( + "/api/v1/user/api-token", + headers={"X-API-Key": "trf_invalidkeyXXXXXXXXXXXXXXXXXX"}, + ) + assert resp.status_code == 401 + + def test_api_key_revoked(self, client): + """Revoked X-API-Key → 401""" + token, _ = _create_user(client, "revoked_apikey@test.com", plan="pro") + # Create token + r = client.post("/api/v1/user/api-token", headers=_auth_header(token)) + assert r.status_code == 201 + raw_key = r.get_json()["token"] + # Revoke it + client.delete("/api/v1/user/api-token", headers=_auth_header(token)) + # Try using revoked key + resp = client.post("/api/v1/user/api-token", headers={"X-API-Key": raw_key}) + assert resp.status_code == 401 + + def test_revoke_then_cannot_auth(self, client): + """Full flow: create → use → revoke → X-API-Key rejected""" + token, _ = _create_user(client, "flow_test@test.com", plan="pro") + # Create + r = client.post("/api/v1/user/api-token", headers=_auth_header(token)) + raw_key = r.get_json()["token"] + # Validate it works (409 because key exists) + r2 = client.post("/api/v1/user/api-token", headers={"X-API-Key": raw_key}) + assert r2.status_code == 409 + # Revoke + client.delete("/api/v1/user/api-token", headers=_auth_header(token)) + # Try again with revoked key + r3 = client.post("/api/v1/user/api-token", headers={"X-API-Key": raw_key}) + assert r3.status_code == 401 + + +# ─── Tests: Webhook ─────────────────────────────────────────────────────────── + + +class TestWebhook: + def test_create_webhook_pro(self, client): + """POST /api/v1/user/webhook — Pro user with provided secret → 201""" + token, _ = _create_user(client, "webhook_pro@test.com", plan="pro") + resp = client.post( + "/api/v1/user/webhook", + headers=_auth_header(token), + json={"url": "https://example.com/hook", "secret": "mysecret123"}, + ) + assert resp.status_code == 201 + data = resp.get_json() + assert data["webhook_url"] == "https://example.com/hook" + assert data["secret"] == "mysecret123" + + def test_create_webhook_auto_secret(self, client): + """POST without secret → auto-generated secret""" + token, _ = _create_user(client, "webhook_auto@test.com", plan="pro") + resp = client.post( + "/api/v1/user/webhook", + headers=_auth_header(token), + json={"url": "https://auto.example.com/hook"}, + ) + assert resp.status_code == 201 + data = resp.get_json() + assert len(data["secret"]) == 64 # token_hex(32) = 64 hex chars + + def test_create_webhook_non_pro_free(self, client): + """Free user → 403""" + token, _ = _create_user(client, "webhook_free@test.com", plan="free") + resp = client.post( + "/api/v1/user/webhook", + headers=_auth_header(token), + json={"url": "https://example.com/hook"}, + ) + assert resp.status_code == 403 + + def test_create_webhook_non_pro_premium(self, client): + """Premium user → 403""" + token, _ = _create_user(client, "webhook_premium@test.com", plan="premium") + resp = client.post( + "/api/v1/user/webhook", + headers=_auth_header(token), + json={"url": "https://example.com/hook"}, + ) + assert resp.status_code == 403 + + def test_create_webhook_url_not_https(self, client): + """HTTP URL → 400""" + token, _ = _create_user(client, "webhook_http@test.com", plan="pro") + resp = client.post( + "/api/v1/user/webhook", + headers=_auth_header(token), + json={"url": "http://example.com/hook"}, + ) + assert resp.status_code == 400 + assert "https" in resp.get_json()["error"].lower() + + def test_create_webhook_missing_url(self, client): + """Missing URL → 400""" + token, _ = _create_user(client, "webhook_nourl@test.com", plan="pro") + resp = client.post( + "/api/v1/user/webhook", + headers=_auth_header(token), + json={}, + ) + assert resp.status_code == 400 + + def test_webhook_upsert(self, client): + """Second POST updates URL (upsert behavior)""" + token, _ = _create_user(client, "webhook_upsert@test.com", plan="pro") + client.post( + "/api/v1/user/webhook", + headers=_auth_header(token), + json={"url": "https://first.example.com/hook"}, + ) + resp = client.post( + "/api/v1/user/webhook", + headers=_auth_header(token), + json={"url": "https://second.example.com/hook"}, + ) + assert resp.status_code == 201 + assert resp.get_json()["webhook_url"] == "https://second.example.com/hook" + + def test_delete_webhook(self, client): + """DELETE /api/v1/user/webhook → 200""" + token, _ = _create_user(client, "webhook_delete@test.com", plan="pro") + client.post( + "/api/v1/user/webhook", + headers=_auth_header(token), + json={"url": "https://delete.example.com/hook"}, + ) + resp = client.delete("/api/v1/user/webhook", headers=_auth_header(token)) + assert resp.status_code == 200 + assert resp.get_json()["deleted"] is True + + def test_delete_webhook_not_configured(self, client): + """DELETE without webhook configured → 404""" + token, _ = _create_user(client, "webhook_notset@test.com", plan="pro") + resp = client.delete("/api/v1/user/webhook", headers=_auth_header(token)) + assert resp.status_code == 404 + + def test_delete_webhook_non_pro(self, client): + """Free user DELETE → 403""" + token, _ = _create_user(client, "webhook_freedelete@test.com", plan="free") + resp = client.delete("/api/v1/user/webhook", headers=_auth_header(token)) + assert resp.status_code == 403 + + +# ─── Tests: dispatch_webhook ────────────────────────────────────────────────── + + +class TestDispatchWebhook: + def test_dispatch_no_webhook_configured(self): + """dispatch_webhook silently returns when no webhook is configured""" + with patch("api_v1.utils_webhook.get_db") as mock_get_db: + mock_conn = MagicMock() + mock_conn.execute.return_value.fetchone.return_value = None + mock_get_db.return_value = mock_conn + + from api_v1.utils_webhook import dispatch_webhook + + # Should not raise, should return silently + dispatch_webhook("nonexistent_user", "new_prediction", {"data": "test"}) + + def test_dispatch_sends_hmac_header(self): + """dispatch_webhook sends correct HMAC-SHA256 signature header""" + test_secret = "testsecret" + test_url = "https://hook.example.com/receive" + test_payload = {"race_id": "R123", "top1": "Cheval Blanc"} + + with ( + patch("api_v1.utils_webhook.get_db") as mock_get_db, + patch("api_v1.utils_webhook.requests.post") as mock_post, + ): + mock_row = MagicMock() + mock_row.__getitem__ = lambda self, key: ( + test_url if key == "url" else test_secret + ) + mock_conn = MagicMock() + mock_conn.execute.return_value.fetchone.return_value = mock_row + mock_get_db.return_value = mock_conn + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_post.return_value = mock_response + + from api_v1.utils_webhook import dispatch_webhook, EVENT_NEW_PREDICTION + + dispatch_webhook("user123", EVENT_NEW_PREDICTION, test_payload) + + assert mock_post.called + call_kwargs = mock_post.call_args + headers_sent = call_kwargs.kwargs.get("headers") or call_kwargs[1].get( + "headers" + ) + assert "X-Turf-Signature" in headers_sent + assert headers_sent["X-Turf-Signature"].startswith("sha256=") + assert headers_sent["X-Turf-Event"] == EVENT_NEW_PREDICTION