Merge branch 'worktree-111-mcp-connecteur' into dev (connecteur MCP, scope B #111)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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",)]
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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()
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user