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 <noreply@anthropic.com>
This commit is contained in:
@@ -33,6 +33,9 @@ USERS_DB_PATH=./users.sqlite
|
|||||||
SECRET_KEY= # à générer : python -c "import secrets; print(secrets.token_hex(32))"
|
SECRET_KEY= # à générer : python -c "import secrets; print(secrets.token_hex(32))"
|
||||||
APP_BASE_URL=http://localhost:8050
|
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,
|
# 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
|
# activer "Sign In with LinkedIn using OpenID Connect", déclarer le redirect URI
|
||||||
# {APP_BASE_URL}/auth/linkedin/callback
|
# {APP_BASE_URL}/auth/linkedin/callback
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -48,6 +48,10 @@ def init_auth(app: Flask) -> None:
|
|||||||
|
|
||||||
app.register_blueprint(auth_bp)
|
app.register_blueprint(auth_bp)
|
||||||
|
|
||||||
|
from src.admin.routes import admin_bp
|
||||||
|
|
||||||
|
app.register_blueprint(admin_bp)
|
||||||
|
|
||||||
init_oauth(app)
|
init_oauth(app)
|
||||||
|
|
||||||
_csrf = CSRFProtect(app)
|
_csrf = CSRFProtect(app)
|
||||||
|
|||||||
@@ -10,3 +10,41 @@ def users_db_path(monkeypatch, tmp_path):
|
|||||||
reset_conn_for_tests()
|
reset_conn_for_tests()
|
||||||
yield db_path
|
yield db_path
|
||||||
reset_conn_for_tests()
|
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
|
||||||
|
|||||||
@@ -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"
|
||||||
Reference in New Issue
Block a user