From e483d7af4dbf0c55a2f5fb383822cdff051cf893 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Fri, 3 Jul 2026 09:46:22 +0200 Subject: [PATCH] feat(admin): add subscription-status mutation route Wires is_admin(), SUBSCRIPTION_STATUSES/get_current/set_status, and log_action() into POST /admin/actions/subscription-status: validates the requested status and that subscription_id matches the user's current subscription, applies the change, and logs an audit entry. Registers the admin blueprint in init_auth() and documents ADMIN_EMAIL in .template.env. Co-Authored-By: Claude Sonnet 5 --- .template.env | 3 ++ src/admin/routes.py | 42 +++++++++++++++++++++ src/auth/setup.py | 4 ++ tests/admin/conftest.py | 38 +++++++++++++++++++ tests/admin/test_routes.py | 76 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 163 insertions(+) create mode 100644 src/admin/routes.py create mode 100644 tests/admin/test_routes.py diff --git a/.template.env b/.template.env index 8172eac..ae5357f 100644 --- a/.template.env +++ b/.template.env @@ -33,6 +33,9 @@ USERS_DB_PATH=./users.sqlite SECRET_KEY= # à générer : python -c "import secrets; print(secrets.token_hex(32))" APP_BASE_URL=http://localhost:8050 +# Panneau admin interne (accès à /admin, protégé par cette adresse) +ADMIN_EMAIL= + # Connexion LinkedIn (OpenID Connect) — créer une app sur le LinkedIn Developer Portal, # activer "Sign In with LinkedIn using OpenID Connect", déclarer le redirect URI # {APP_BASE_URL}/auth/linkedin/callback diff --git a/src/admin/routes.py b/src/admin/routes.py new file mode 100644 index 0000000..ba9ae2d --- /dev/null +++ b/src/admin/routes.py @@ -0,0 +1,42 @@ +from flask import Blueprint, abort, redirect, request +from flask_login import current_user + +from src.admin.db import log_action +from src.admin.guard import is_admin +from src.subscriptions.db import SUBSCRIPTION_STATUSES, get_current, set_status + +admin_bp = Blueprint("admin", __name__, url_prefix="/admin/actions") + + +@admin_bp.before_request +def _require_admin(): + if not is_admin(): + abort(404) + + +@admin_bp.route("/subscription-status", methods=["POST"]) +def subscription_status(): + user_id = request.form.get("user_id", type=int) + subscription_id = request.form.get("subscription_id", type=int) + status = request.form.get("status", "") + + if user_id is None or subscription_id is None: + abort(400) + + current = get_current(user_id) + if ( + status not in SUBSCRIPTION_STATUSES + or current is None + or current["id"] != subscription_id + ): + return redirect(f"/admin/user/{user_id}?error=invalid_status") + + old_status = current["status"] + set_status(subscription_id, status) + log_action( + current_user.email, + "subscription_status_change", + user_id, + f"{old_status} → {status}", + ) + return redirect(f"/admin/user/{user_id}?status_changed=1") diff --git a/src/auth/setup.py b/src/auth/setup.py index ea6c717..5e4197f 100644 --- a/src/auth/setup.py +++ b/src/auth/setup.py @@ -48,6 +48,10 @@ def init_auth(app: Flask) -> None: app.register_blueprint(auth_bp) + from src.admin.routes import admin_bp + + app.register_blueprint(admin_bp) + init_oauth(app) _csrf = CSRFProtect(app) diff --git a/tests/admin/conftest.py b/tests/admin/conftest.py index ce77c3b..30ffc09 100644 --- a/tests/admin/conftest.py +++ b/tests/admin/conftest.py @@ -10,3 +10,41 @@ def users_db_path(monkeypatch, tmp_path): reset_conn_for_tests() yield db_path reset_conn_for_tests() + + +@pytest.fixture +def admin_app(users_db_path, monkeypatch): + from flask import Flask + + from src.auth.setup import init_auth + from src.subscriptions.setup import init_subscriptions + + monkeypatch.setenv("SECRET_KEY", "test-secret-key") + monkeypatch.setenv("APP_BASE_URL", "http://localhost:8050") + monkeypatch.setenv("FRISBII_PLAN_SIMPLE", "plan_simple") + monkeypatch.setenv("FRISBII_PLAN_SOUTIEN", "plan_soutien") + monkeypatch.setenv("FRISBII_WEBHOOK_SECRET", "s3cr3t") + flask_app = Flask(__name__) + flask_app.config["WTF_CSRF_ENABLED"] = False + init_auth(flask_app) + init_subscriptions(flask_app) + return flask_app + + +@pytest.fixture +def admin_client(admin_app): + return admin_app.test_client() + + +@pytest.fixture +def logged_in_admin_client(admin_app, monkeypatch): + from src.auth import db as auth_db + + monkeypatch.setenv("ADMIN_EMAIL", "admin@ex.fr") + uid = auth_db.create_user("admin@ex.fr", "hash") + auth_db.set_email_verified(uid) + client = admin_app.test_client() + with client.session_transaction() as sess: + sess["_user_id"] = str(uid) + sess["_fresh"] = True + return client, uid diff --git a/tests/admin/test_routes.py b/tests/admin/test_routes.py new file mode 100644 index 0000000..a105ba1 --- /dev/null +++ b/tests/admin/test_routes.py @@ -0,0 +1,76 @@ +import itertools + +from src.auth import db as auth_db +from src.subscriptions import db as sub_db + +_email_counter = itertools.count(1) + + +def _make_target_with_subscription(): + uid = auth_db.create_user(f"target{next(_email_counter)}@ex.fr", "hash") + _handle, sub_id = sub_db.create_pending(uid, "cust-1", "simple") + return uid, sub_id + + +def test_subscription_status_requires_admin(admin_client): + resp = admin_client.post( + "/admin/actions/subscription-status", + data={"user_id": "1", "subscription_id": "1", "status": "active"}, + ) + assert resp.status_code == 404 + + +def test_subscription_status_rejects_invalid_status(logged_in_admin_client): + client, _admin_uid = logged_in_admin_client + uid, sub_id = _make_target_with_subscription() + + resp = client.post( + "/admin/actions/subscription-status", + data={"user_id": str(uid), "subscription_id": str(sub_id), "status": "bogus"}, + ) + + assert resp.status_code == 302 + assert resp.headers["Location"] == f"/admin/user/{uid}?error=invalid_status" + assert sub_db.get_current(uid)["status"] == "pending" + + +def test_subscription_status_rejects_mismatched_subscription(logged_in_admin_client): + client, _admin_uid = logged_in_admin_client + uid, _sub_id = _make_target_with_subscription() + other_uid, other_sub_id = _make_target_with_subscription() + + resp = client.post( + "/admin/actions/subscription-status", + data={ + "user_id": str(uid), + "subscription_id": str(other_sub_id), + "status": "active", + }, + ) + + assert resp.status_code == 302 + assert resp.headers["Location"] == f"/admin/user/{uid}?error=invalid_status" + assert sub_db.get_current(other_uid)["status"] == "pending" + + +def test_subscription_status_success_updates_and_logs(logged_in_admin_client): + from src.admin.db import list_actions + + client, _admin_uid = logged_in_admin_client + uid, sub_id = _make_target_with_subscription() + + resp = client.post( + "/admin/actions/subscription-status", + data={"user_id": str(uid), "subscription_id": str(sub_id), "status": "active"}, + ) + + assert resp.status_code == 302 + assert resp.headers["Location"] == f"/admin/user/{uid}?status_changed=1" + assert sub_db.get_current(uid)["status"] == "active" + + actions = list_actions() + assert len(actions) == 1 + assert actions[0]["action"] == "subscription_status_change" + assert actions[0]["target_user_id"] == uid + assert actions[0]["details"] == "pending → active" + assert actions[0]["admin_email"] == "admin@ex.fr"