Merge branch 'worktree-111-mcp-connecteur' into dev (connecteur MCP, scope B #111)

This commit is contained in:
Colin Maudry
2026-07-10 16:13:06 +02:00
21 changed files with 2347 additions and 16 deletions
+19
View File
@@ -57,3 +57,22 @@ def test_valid_token_sets_g_and_calls_view(temp_db):
)
assert resp.status_code == 200
assert resp.get_json()["token_id"] == token_id
def test_mcp_kind_token_rejected_by_rest_api(temp_db):
token, _ = tokens_db.create_token(temp_db, "x", user_id=1, kind="mcp")
app = _make_app()
resp = app.test_client().get(
"/protected", headers={"Authorization": f"Bearer {token}"}
)
assert resp.status_code == 401
assert resp.get_json()["message"] == "invalid_token"
def test_api_kind_token_accepted_by_rest_api(temp_db):
token, _ = tokens_db.create_token(temp_db, "x", kind="api")
app = _make_app()
resp = app.test_client().get(
"/protected", headers={"Authorization": f"Bearer {token}"}
)
assert resp.status_code == 200
+21
View File
@@ -0,0 +1,21 @@
import sqlite3
def test_init_api_creates_api_tokens_table(monkeypatch, tmp_path):
from flask import Flask
from src.api import init_api, tracking
db_path = tmp_path / "users.test.sqlite"
monkeypatch.setenv("USERS_DB_PATH", str(db_path))
server = Flask(__name__)
init_api(server)
try:
with sqlite3.connect(str(db_path)) as conn:
rows = conn.execute(
"SELECT name FROM sqlite_master "
"WHERE type='table' AND name='api_tokens'"
).fetchall()
finally:
tracking.stop_worker()
assert rows == [("api_tokens",)]
+44
View File
@@ -0,0 +1,44 @@
def test_migration_0007_adds_kind_to_legacy_api_tokens(monkeypatch, tmp_path):
from src import migrations
from src.auth import db as auth_db
from src.subscriptions import db as sub_db
db_path = tmp_path / "users.test.sqlite"
monkeypatch.setenv("USERS_DB_PATH", str(db_path))
auth_db.reset_conn_for_tests()
auth_db.init_schema()
sub_db.init_schema()
conn = auth_db.get_conn()
conn.execute("DROP TABLE IF EXISTS api_tokens")
# ancienne table SANS colonne kind
conn.execute(
"CREATE TABLE api_tokens ("
"id INTEGER PRIMARY KEY, token_hash TEXT NOT NULL UNIQUE, "
"label TEXT NOT NULL, user_id INTEGER, created_at TEXT NOT NULL, "
"last_used_at TEXT, count_total INTEGER NOT NULL DEFAULT 0, revoked_at TEXT)"
)
conn.commit()
migrations.apply_pending()
cols = [r[1] for r in conn.execute("PRAGMA table_info(api_tokens)").fetchall()]
assert "kind" in cols
# idempotent : un second passage ne lève pas
migrations.apply_pending()
auth_db.reset_conn_for_tests()
def test_apply_pending_tolerates_missing_api_tokens_table(monkeypatch, tmp_path):
from src import migrations
from src.auth import db as auth_db
from src.subscriptions import db as sub_db
db_path = tmp_path / "users.test.sqlite"
monkeypatch.setenv("USERS_DB_PATH", str(db_path))
auth_db.reset_conn_for_tests()
auth_db.init_schema()
sub_db.init_schema()
# api_tokens volontairement NON créée (chemin init_subscriptions sans init_api)
migrations.apply_pending() # ne doit pas lever
auth_db.reset_conn_for_tests()
+39
View File
@@ -62,3 +62,42 @@ def test_list_tokens_returns_all(temp_db):
tokens_db.create_token(temp_db, "b")
rows = tokens_db.list_tokens(temp_db)
assert [r["label"] for r in rows] == ["a", "b"]
def test_create_token_defaults_to_api_kind(temp_db):
token, token_id = tokens_db.create_token(temp_db, "x")
row = tokens_db.get_token_by_plaintext(temp_db, token)
assert row["kind"] == "api"
def test_create_token_with_mcp_kind(temp_db):
token, _ = tokens_db.create_token(temp_db, "x", user_id=7, kind="mcp")
row = tokens_db.get_token_by_plaintext(temp_db, token)
assert row["kind"] == "mcp"
assert row["user_id"] == 7
def test_list_user_tokens_filters_by_user_and_kind(temp_db):
tokens_db.create_token(temp_db, "mcp-u1", user_id=1, kind="mcp")
tokens_db.create_token(temp_db, "api-u1", user_id=1, kind="api")
tokens_db.create_token(temp_db, "mcp-u2", user_id=2, kind="mcp")
rows = tokens_db.list_user_tokens(temp_db, 1, "mcp")
assert [r["label"] for r in rows] == ["mcp-u1"]
def test_revoke_user_token_revokes_own(temp_db):
token, token_id = tokens_db.create_token(temp_db, "x", user_id=1, kind="mcp")
assert tokens_db.revoke_user_token(temp_db, token_id, 1) is True
assert tokens_db.get_token_by_plaintext(temp_db, token)["revoked_at"] is not None
def test_revoke_user_token_refuses_other_owner(temp_db):
token, token_id = tokens_db.create_token(temp_db, "x", user_id=1, kind="mcp")
assert tokens_db.revoke_user_token(temp_db, token_id, 999) is False
assert tokens_db.get_token_by_plaintext(temp_db, token)["revoked_at"] is None
def test_revoke_user_token_already_revoked_returns_false(temp_db):
_, token_id = tokens_db.create_token(temp_db, "x", user_id=1, kind="mcp")
assert tokens_db.revoke_user_token(temp_db, token_id, 1) is True
assert tokens_db.revoke_user_token(temp_db, token_id, 1) is False
+26
View File
@@ -0,0 +1,26 @@
def test_section_connecteur_mcp_present():
from src.pages._compte_shell import SECTIONS
keys = {s["key"]: s for s in SECTIONS}
assert "mcp" in keys
assert keys["mcp"]["label"] == "Connecteur MCP"
assert keys["mcp"]["href"] == "/compte/mcp"
assert keys["mcp"]["require_subscription"] is True
def test_page_module_registers_and_builds_client_instructions():
# importe l'app pour la découverte use_pages en contexte propre
from src.app import app # noqa: F401
from src.pages.compte import mcp as mcp_page
# helper pur : construit les 4 blocs d'instructions clients
blocks = mcp_page.client_instructions(
"https://colibre.fr/_mcp", "colibre_TESTTOKEN"
)
text = str(blocks)
assert "colibre_TESTTOKEN" in text
assert "https://colibre.fr/_mcp" in text
for client_name in ("Claude", "Gemini", "Mistral", "ChatGPT"):
assert client_name in text
# caveat ChatGPT (OAuth / itération future)
assert "OAuth" in text
+114
View File
@@ -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"]
+26
View File
@@ -0,0 +1,26 @@
import importlib
def test_mcp_endpoint_guarded_and_csrf_exempt(monkeypatch, tmp_path):
# DB éphémère + secrets requis par init_auth/init_subscriptions.
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")
monkeypatch.setenv("DASH_MCP_ENABLED", "true")
from src.auth import db as auth_db
auth_db.reset_conn_for_tests()
import src.app as app_module
app_module = importlib.reload(app_module)
client = app_module.app.server.test_client()
# Pas de jeton : le garde renvoie 401 (et NON une erreur CSRF 400/403),
# ce qui prouve exemption CSRF + garde câblés sur /_mcp.
resp = client.post("/_mcp", json={"jsonrpc": "2.0", "method": "ping", "id": 1})
assert resp.status_code == 401
assert resp.headers.get("WWW-Authenticate") == 'Bearer realm="colibre-mcp"'
auth_db.reset_conn_for_tests()
+122
View File
@@ -0,0 +1,122 @@
import pytest
from flask import Flask
@pytest.fixture
def mcp_app(monkeypatch, tmp_path):
from src.api import tokens_db
from src.auth import db as auth_db
from src.mcp.auth import init_mcp_auth
from src.subscriptions import db as sub_db
db_path = tmp_path / "users.test.sqlite"
monkeypatch.setenv("USERS_DB_PATH", str(db_path))
auth_db.reset_conn_for_tests()
auth_db.init_schema()
sub_db.init_schema()
tokens_db.init_schema(db_path)
app = Flask(__name__)
@app.route("/_mcp", methods=["GET", "POST"])
def _mcp():
return "ok", 200
init_mcp_auth(app)
yield app, db_path
auth_db.reset_conn_for_tests()
def _subscribed_uid():
from src.auth import db as auth_db
from src.subscriptions import db as sub_db
uid = auth_db.create_user("mcp@ex.fr", "hash")
_, sub_id = sub_db.create_pending(uid, "colibre-1", "simple")
sub_db.set_status(sub_id, "active")
return uid
def test_missing_header_401_with_challenge(mcp_app):
app, _ = mcp_app
resp = app.test_client().post("/_mcp")
assert resp.status_code == 401
assert resp.headers["WWW-Authenticate"] == 'Bearer realm="colibre-mcp"'
def test_empty_bearer_401(mcp_app):
app, _ = mcp_app
resp = app.test_client().post("/_mcp", headers={"Authorization": "Bearer "})
assert resp.status_code == 401
def test_unknown_token_401(mcp_app):
app, _ = mcp_app
resp = app.test_client().post(
"/_mcp", headers={"Authorization": "Bearer colibre_unknown"}
)
assert resp.status_code == 401
def test_revoked_mcp_token_401(mcp_app):
from src.api import tokens_db
app, db_path = mcp_app
uid = _subscribed_uid()
token, tid = tokens_db.create_token(db_path, "x", user_id=uid, kind="mcp")
tokens_db.revoke_user_token(db_path, tid, uid)
resp = app.test_client().post("/_mcp", headers={"Authorization": f"Bearer {token}"})
assert resp.status_code == 401
def test_api_kind_token_rejected_401(mcp_app):
from src.api import tokens_db
app, db_path = mcp_app
uid = _subscribed_uid()
token, _ = tokens_db.create_token(db_path, "x", user_id=uid, kind="api")
resp = app.test_client().post("/_mcp", headers={"Authorization": f"Bearer {token}"})
assert resp.status_code == 401
def test_mcp_token_without_user_403(mcp_app):
from src.api import tokens_db
app, db_path = mcp_app
token, _ = tokens_db.create_token(db_path, "x", user_id=None, kind="mcp")
resp = app.test_client().post("/_mcp", headers={"Authorization": f"Bearer {token}"})
assert resp.status_code == 403
def test_mcp_token_no_active_subscription_403(mcp_app):
from src.api import tokens_db
from src.auth import db as auth_db
app, db_path = mcp_app
uid = auth_db.create_user("nosub@ex.fr", "hash") # aucun abonnement
token, _ = tokens_db.create_token(db_path, "x", user_id=uid, kind="mcp")
resp = app.test_client().post("/_mcp", headers={"Authorization": f"Bearer {token}"})
assert resp.status_code == 403
def test_mcp_token_active_subscription_passes_and_increments(mcp_app):
from src.api import tokens_db
app, db_path = mcp_app
uid = _subscribed_uid()
token, _ = tokens_db.create_token(db_path, "x", user_id=uid, kind="mcp")
resp = app.test_client().post("/_mcp", headers={"Authorization": f"Bearer {token}"})
assert resp.status_code == 200
assert tokens_db.get_token_by_plaintext(db_path, token)["count_total"] == 1
def test_tous_abonnes_bypasses_subscription(mcp_app, monkeypatch):
from src.api import tokens_db
from src.auth import db as auth_db
monkeypatch.setattr("src.mcp.auth.TOUS_ABONNES", True)
app, db_path = mcp_app
uid = auth_db.create_user("nosub2@ex.fr", "hash")
token, _ = tokens_db.create_token(db_path, "x", user_id=uid, kind="mcp")
resp = app.test_client().post("/_mcp", headers={"Authorization": f"Bearer {token}"})
assert resp.status_code == 200