feat(mcp): blueprint création/révocation de jetons MCP (scope B #111)
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
import os
|
||||
|
||||
from flask import Blueprint, redirect, request, session
|
||||
from flask_login import current_user
|
||||
|
||||
from src.api import tokens_db
|
||||
|
||||
mcp_account_bp = Blueprint("mcp_account", __name__)
|
||||
|
||||
_LABEL_MAX = 100
|
||||
|
||||
|
||||
def _has_active_subscription() -> bool:
|
||||
# Réutilise la vérification canonique (gère TOUS_ABONNES).
|
||||
from src.pages._compte_shell import current_user_has_subscription
|
||||
|
||||
return current_user_has_subscription()
|
||||
|
||||
|
||||
def _guard():
|
||||
"""Renvoie une redirection si l'accès est refusé, sinon None."""
|
||||
if not current_user.is_authenticated:
|
||||
return redirect("/connexion?next=/compte/mcp")
|
||||
if not _has_active_subscription():
|
||||
return redirect("/compte/abonnement")
|
||||
return None
|
||||
|
||||
|
||||
@mcp_account_bp.route("/compte/mcp/creer", methods=["POST"])
|
||||
def creer():
|
||||
denied = _guard()
|
||||
if denied is not None:
|
||||
return denied
|
||||
label = (request.form.get("label") or "").strip()[:_LABEL_MAX] or "Sans nom"
|
||||
token, _ = tokens_db.create_token(
|
||||
os.environ["USERS_DB_PATH"], label, user_id=current_user.id, kind="mcp"
|
||||
)
|
||||
session["mcp_new_token"] = token
|
||||
return redirect("/compte/mcp")
|
||||
|
||||
|
||||
@mcp_account_bp.route("/compte/mcp/revoquer/<int:token_id>", methods=["POST"])
|
||||
def revoquer(token_id):
|
||||
denied = _guard()
|
||||
if denied is not None:
|
||||
return denied
|
||||
tokens_db.revoke_user_token(os.environ["USERS_DB_PATH"], token_id, current_user.id)
|
||||
return redirect("/compte/mcp")
|
||||
@@ -0,0 +1,114 @@
|
||||
import pytest
|
||||
from flask import Flask
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def account_client(monkeypatch, tmp_path):
|
||||
from src.api import tokens_db
|
||||
from src.auth import db as auth_db
|
||||
from src.auth.setup import init_auth
|
||||
from src.mcp.account import mcp_account_bp
|
||||
from src.subscriptions import db as sub_db
|
||||
|
||||
monkeypatch.setenv("USERS_DB_PATH", str(tmp_path / "users.test.sqlite"))
|
||||
monkeypatch.setenv("SECRET_KEY", "test-secret-key")
|
||||
monkeypatch.setenv("APP_BASE_URL", "http://localhost:8050")
|
||||
auth_db.reset_conn_for_tests()
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config["WTF_CSRF_ENABLED"] = False
|
||||
init_auth(app) # login manager + CSRF (désactivé) + schéma auth
|
||||
sub_db.init_schema()
|
||||
db_path = tmp_path / "users.test.sqlite"
|
||||
tokens_db.init_schema(db_path)
|
||||
app.register_blueprint(mcp_account_bp)
|
||||
|
||||
yield app, db_path
|
||||
auth_db.reset_conn_for_tests()
|
||||
|
||||
|
||||
def _login(app, uid):
|
||||
client = app.test_client()
|
||||
with client.session_transaction() as sess:
|
||||
sess["_user_id"] = str(uid)
|
||||
sess["_fresh"] = True
|
||||
return client
|
||||
|
||||
|
||||
def _subscribed_uid():
|
||||
from src.auth import db as auth_db
|
||||
from src.subscriptions import db as sub_db
|
||||
|
||||
uid = auth_db.create_user("sub@ex.fr", "hash")
|
||||
_, sub_id = sub_db.create_pending(uid, "colibre-1", "simple")
|
||||
sub_db.set_status(sub_id, "active")
|
||||
return uid
|
||||
|
||||
|
||||
def test_creer_generates_mcp_token_for_subscriber(account_client):
|
||||
from src.api import tokens_db
|
||||
|
||||
app, db_path = account_client
|
||||
uid = _subscribed_uid()
|
||||
client = _login(app, uid)
|
||||
|
||||
resp = client.post("/compte/mcp/creer", data={"label": "Claude portable"})
|
||||
assert resp.status_code == 302
|
||||
assert resp.headers["Location"].endswith("/compte/mcp")
|
||||
|
||||
with client.session_transaction() as sess:
|
||||
assert sess.get("mcp_new_token", "").startswith("colibre_")
|
||||
|
||||
rows = tokens_db.list_user_tokens(db_path, uid, "mcp")
|
||||
assert [r["label"] for r in rows] == ["Claude portable"]
|
||||
assert rows[0]["kind"] == "mcp"
|
||||
assert rows[0]["user_id"] == uid
|
||||
|
||||
|
||||
def test_revoquer_own_token(account_client):
|
||||
from src.api import tokens_db
|
||||
|
||||
app, db_path = account_client
|
||||
uid = _subscribed_uid()
|
||||
client = _login(app, uid)
|
||||
token, tid = tokens_db.create_token(db_path, "x", user_id=uid, kind="mcp")
|
||||
|
||||
resp = client.post(f"/compte/mcp/revoquer/{tid}")
|
||||
assert resp.status_code == 302
|
||||
assert tokens_db.get_token_by_plaintext(db_path, token)["revoked_at"] is not None
|
||||
|
||||
|
||||
def test_revoquer_other_users_token_is_noop(account_client):
|
||||
from src.api import tokens_db
|
||||
|
||||
app, db_path = account_client
|
||||
uid = _subscribed_uid()
|
||||
client = _login(app, uid)
|
||||
other_token, other_id = tokens_db.create_token(
|
||||
db_path, "y", user_id=99999, kind="mcp"
|
||||
)
|
||||
|
||||
resp = client.post(f"/compte/mcp/revoquer/{other_id}")
|
||||
assert resp.status_code == 302
|
||||
assert tokens_db.get_token_by_plaintext(db_path, other_token)["revoked_at"] is None
|
||||
|
||||
|
||||
def test_creer_blocked_without_subscription(account_client):
|
||||
from src.api import tokens_db
|
||||
from src.auth import db as auth_db
|
||||
|
||||
app, db_path = account_client
|
||||
uid = auth_db.create_user("nosub@ex.fr", "hash") # pas d'abonnement
|
||||
client = _login(app, uid)
|
||||
|
||||
resp = client.post("/compte/mcp/creer", data={"label": "x"})
|
||||
assert resp.status_code == 302
|
||||
assert resp.headers["Location"].endswith("/compte/abonnement")
|
||||
assert tokens_db.list_user_tokens(db_path, uid, "mcp") == []
|
||||
|
||||
|
||||
def test_creer_blocked_when_anonymous(account_client):
|
||||
app, db_path = account_client
|
||||
resp = app.test_client().post("/compte/mcp/creer", data={"label": "x"})
|
||||
assert resp.status_code == 302
|
||||
assert "/connexion" in resp.headers["Location"]
|
||||
Reference in New Issue
Block a user