Jetons MCP chiffrés au repos + bouton « Copier le jeton » sur /compte/mcp
Les jetons MCP sont désormais chiffrés (Fernet dérivée de SECRET_KEY) en plus du hash conservé pour l'auth, permettant leur ré-affichage/copie à tout moment. Getter scopé au propriétaire, dégradation propre si clé absente/changée. Migration 0013 + colonne token_enc. UI alignée sur « Copier le lien » des vues. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -101,3 +101,34 @@ 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
|
||||
|
||||
|
||||
def test_create_token_stores_recoverable_encrypted_token(temp_db, monkeypatch):
|
||||
monkeypatch.setenv("SECRET_KEY", "s3cr3t-test-key")
|
||||
token, token_id = tokens_db.create_token(temp_db, "x", user_id=7, kind="mcp")
|
||||
with sqlite3.connect(str(temp_db)) as conn:
|
||||
enc = conn.execute(
|
||||
"SELECT token_enc FROM api_tokens WHERE id = ?", (token_id,)
|
||||
).fetchone()[0]
|
||||
assert enc is not None
|
||||
assert token not in enc # chiffré, jamais en clair dans la base
|
||||
assert tokens_db.get_token_plaintext_for_user(temp_db, token_id, 7) == token
|
||||
|
||||
|
||||
def test_get_token_plaintext_scoped_to_owner(temp_db, monkeypatch):
|
||||
monkeypatch.setenv("SECRET_KEY", "s3cr3t-test-key")
|
||||
_, token_id = tokens_db.create_token(temp_db, "x", user_id=1, kind="mcp")
|
||||
assert tokens_db.get_token_plaintext_for_user(temp_db, token_id, 999) is None
|
||||
|
||||
|
||||
def test_get_token_plaintext_none_when_no_key(temp_db, monkeypatch):
|
||||
monkeypatch.delenv("SECRET_KEY", raising=False)
|
||||
_, token_id = tokens_db.create_token(temp_db, "x", user_id=1, kind="mcp")
|
||||
assert tokens_db.get_token_plaintext_for_user(temp_db, token_id, 1) is None
|
||||
|
||||
|
||||
def test_get_token_plaintext_none_after_key_rotation(temp_db, monkeypatch):
|
||||
monkeypatch.setenv("SECRET_KEY", "key-A")
|
||||
_, token_id = tokens_db.create_token(temp_db, "x", user_id=1, kind="mcp")
|
||||
monkeypatch.setenv("SECRET_KEY", "key-B") # rotation → indéchiffrable
|
||||
assert tokens_db.get_token_plaintext_for_user(temp_db, token_id, 1) is None
|
||||
|
||||
@@ -44,6 +44,45 @@ def test_prompt_tips_mentions_columns_and_examples():
|
||||
assert "lien" in text # mention du lien vers la fiche marché
|
||||
|
||||
|
||||
def test_token_row_has_copy_button_when_token_available():
|
||||
from src.app import app # noqa: F401
|
||||
from src.pages.compte.mcp import _token_row
|
||||
|
||||
text = str(
|
||||
_token_row(
|
||||
{
|
||||
"id": 42,
|
||||
"label": "l",
|
||||
"created_at": "c",
|
||||
"last_used_at": None,
|
||||
"revoked_at": None,
|
||||
"token_plain": "colibre_abc123",
|
||||
}
|
||||
)
|
||||
)
|
||||
assert "Copier le jeton" in text
|
||||
assert "colibre_abc123" in text # contenu du presse-papier (dcc.Clipboard)
|
||||
|
||||
|
||||
def test_token_row_no_copy_button_when_token_unavailable():
|
||||
from src.app import app # noqa: F401
|
||||
from src.pages.compte.mcp import _token_row
|
||||
|
||||
text = str(
|
||||
_token_row(
|
||||
{
|
||||
"id": 42,
|
||||
"label": "l",
|
||||
"created_at": "c",
|
||||
"last_used_at": None,
|
||||
"revoked_at": None,
|
||||
"token_plain": None, # ancien jeton haché, non récupérable
|
||||
}
|
||||
)
|
||||
)
|
||||
assert "Copier le jeton" not in text
|
||||
|
||||
|
||||
def _collect_titles(component):
|
||||
# Parcourt récursivement les AccordionItem pour collecter leurs `title`.
|
||||
found = []
|
||||
|
||||
Reference in New Issue
Block a user