feat: intégration TikTak #197-200 + scoped tokens, webhooks filtrés, listes mtime/size ; fix e2e-server BUG-112
This commit is contained in:
@@ -0,0 +1,243 @@
|
||||
# tests/test_scoped_tokens.py — #199 (Gitea #2) : clés API à portée limitée.
|
||||
"""Couvre :
|
||||
|
||||
- création d'une clé limitée (``vaults`` + ``scope=files``) : portée exposée
|
||||
dans le record et la liste ;
|
||||
- la clé limitée lit/écrit les fichiers de son vault (REST) ;
|
||||
- tout autre vault est refusé (403, via ``check_vault_access``) ;
|
||||
- les endpoints hors périmètre sont refusés (403, via le middleware —
|
||||
y compris la gestion des clés elles-mêmes, anti-escalade) ;
|
||||
- les clés sans portée fonctionnent comme avant (non-régression) ;
|
||||
- validation à la création (scope inconnu → 400, vault inconnu → 400,
|
||||
vault non autorisé → 403) ;
|
||||
- niveau outils MCP : ``call_tool`` autorise les outils fichiers et refuse
|
||||
les autres (``token_scope_denied``), y compris les outils sans vault.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from backend.tools.api import ToolPermissionError, call_tool
|
||||
|
||||
from tests.test_tools import _ctx
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def scoped_client(tmp_path, monkeypatch):
|
||||
"""Auth-enabled TestClient in an isolated data dir (admin / chab30)."""
|
||||
data_dir = tmp_path / "data"
|
||||
data_dir.mkdir()
|
||||
from backend.auth.password import hash_password
|
||||
|
||||
users = {
|
||||
"version": 1,
|
||||
"users": {
|
||||
"admin": {
|
||||
"id": "admin-1", "username": "admin", "display_name": "admin",
|
||||
"password_hash": hash_password("chab30"), "role": "admin",
|
||||
"vaults": ["*"], "active": True,
|
||||
"created_at": "2026-01-01T00:00:00",
|
||||
},
|
||||
"bob": {
|
||||
"id": "bob-1", "username": "bob", "display_name": "bob",
|
||||
"password_hash": hash_password("chab30"), "role": "user",
|
||||
"vaults": ["TestVault"], "active": True,
|
||||
"created_at": "2026-01-01T00:00:00",
|
||||
},
|
||||
},
|
||||
}
|
||||
(data_dir / "users.json").write_text(json.dumps(users), encoding="utf-8")
|
||||
src_secret = Path("data/secret.key")
|
||||
if src_secret.exists():
|
||||
shutil.copy2(str(src_secret), str(data_dir / "secret.key"))
|
||||
|
||||
vault = os.path.abspath("test_vault")
|
||||
orig_cwd = os.getcwd()
|
||||
os.chdir(str(tmp_path))
|
||||
os.environ["VAULT_1_NAME"] = "TestVault"
|
||||
os.environ["VAULT_1_PATH"] = vault
|
||||
os.environ["OBSIGATE_AUTH_ENABLED"] = "true"
|
||||
os.environ["OBSIGATE_WATCHER_ENABLED"] = "false"
|
||||
|
||||
import backend.main
|
||||
backend.main._load_config = lambda: {"watcher_enabled": False}
|
||||
|
||||
from backend.indexer import build_index, index
|
||||
import asyncio
|
||||
for key in list(index.keys()):
|
||||
del index[key]
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
loop.run_until_complete(build_index())
|
||||
from backend.search import init_inverted_index
|
||||
init_inverted_index()
|
||||
|
||||
import backend.auth.jwt_handler as jh
|
||||
jh._revoked_map = {}
|
||||
jh._revoked_loaded = False
|
||||
jh._touch_last_write.clear()
|
||||
|
||||
# Pas de context manager : le lifespan relancerait un rebuild d'index en
|
||||
# tâche de fond qui vide l'index pendant les appels outils directs
|
||||
# (même pattern que tests/conftest.py).
|
||||
client = TestClient(backend.main.app, raise_server_exceptions=False)
|
||||
try:
|
||||
yield client
|
||||
finally:
|
||||
jh._revoked_map = {}
|
||||
jh._revoked_loaded = False
|
||||
os.chdir(orig_cwd)
|
||||
shutil.rmtree(str(tmp_path), ignore_errors=True)
|
||||
for k in ["VAULT_1_NAME", "VAULT_1_PATH", "OBSIGATE_AUTH_ENABLED",
|
||||
"OBSIGATE_WATCHER_ENABLED"]:
|
||||
os.environ.pop(k, None)
|
||||
|
||||
|
||||
_TEST_PW = "chab" + "30"
|
||||
|
||||
|
||||
def _login(client, username="admin", password=_TEST_PW):
|
||||
resp = client.post("/api/auth/login", json={"username": username, "password": password})
|
||||
assert resp.status_code == 200, resp.text
|
||||
return resp.json()["access_token"]
|
||||
|
||||
|
||||
def _hdr(token):
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
def _create(client, token, name="scoped", expiry="30d", **extra):
|
||||
body = {"name": name, "expiry": expiry, **extra}
|
||||
resp = client.post("/api/auth/tokens", json=body, headers=_hdr(token))
|
||||
assert resp.status_code == 200, resp.text
|
||||
return resp.json()
|
||||
|
||||
|
||||
class TestCreateScoped:
|
||||
def test_record_exposes_scope_and_vaults(self, scoped_client):
|
||||
admin = _login(scoped_client)
|
||||
created = _create(scoped_client, admin, vaults=["TestVault"], scope="files")
|
||||
assert created["scope"] == "files"
|
||||
assert created["vaults"] == ["TestVault"]
|
||||
listing = scoped_client.get("/api/auth/tokens", headers=_hdr(admin)).json()
|
||||
entry = next(t for t in listing["tokens"] if t["jti"] == created["jti"])
|
||||
assert entry["scope"] == "files" and entry["vaults"] == ["TestVault"]
|
||||
|
||||
def test_full_scope_default(self, scoped_client):
|
||||
admin = _login(scoped_client)
|
||||
created = _create(scoped_client, admin)
|
||||
assert created["scope"] == "full"
|
||||
|
||||
def test_invalid_scope_rejected(self, scoped_client):
|
||||
admin = _login(scoped_client)
|
||||
resp = scoped_client.post("/api/auth/tokens",
|
||||
json={"name": "x", "expiry": "30d", "scope": "root"},
|
||||
headers=_hdr(admin))
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_unknown_vault_rejected(self, scoped_client):
|
||||
admin = _login(scoped_client)
|
||||
resp = scoped_client.post("/api/auth/tokens",
|
||||
json={"name": "x", "expiry": "30d", "vaults": ["Nope"]},
|
||||
headers=_hdr(admin))
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_vault_outside_creator_access_rejected(self, scoped_client):
|
||||
bob = _login(scoped_client, "bob")
|
||||
resp = scoped_client.post("/api/auth/tokens",
|
||||
json={"name": "x", "expiry": "30d", "vaults": ["Autre"]},
|
||||
headers=_hdr(bob))
|
||||
assert resp.status_code in (400, 403)
|
||||
|
||||
|
||||
class TestScopedRestAccess:
|
||||
def test_reads_and_writes_own_vault(self, scoped_client):
|
||||
import uuid
|
||||
|
||||
admin = _login(scoped_client)
|
||||
key = _create(scoped_client, admin, vaults=["TestVault"], scope="files")["token"]
|
||||
assert scoped_client.get("/api/vaults", headers=_hdr(key)).status_code == 200
|
||||
assert scoped_client.get("/api/vault/TestVault/paths", headers=_hdr(key)).status_code == 200
|
||||
assert scoped_client.get("/api/vault/TestVault/files", headers=_hdr(key)).status_code == 200
|
||||
# Fichier unique par exécution + nettoyage : le vault de test est le
|
||||
# vrai répertoire du dépôt, on n'y laisse aucune trace.
|
||||
path = f"scoped-sync-probe-{uuid.uuid4().hex[:8]}.md"
|
||||
try:
|
||||
created = scoped_client.post("/api/file/TestVault", json={"path": path, "content": "# a\n"},
|
||||
headers=_hdr(key))
|
||||
assert created.status_code == 200, created.text
|
||||
raw = scoped_client.get("/api/file/TestVault/raw", params={"path": path},
|
||||
headers=_hdr(key))
|
||||
assert raw.status_code == 200
|
||||
saved = scoped_client.put("/api/file/TestVault/save", params={"path": path},
|
||||
json={"content": "# a\nmodifié\n"}, headers=_hdr(key))
|
||||
assert saved.status_code == 200, saved.text
|
||||
finally:
|
||||
scoped_client.delete("/api/file/TestVault", params={"path": path}, headers=_hdr(key))
|
||||
|
||||
def test_other_vault_refused(self, scoped_client):
|
||||
admin = _login(scoped_client)
|
||||
key = _create(scoped_client, admin, vaults=["TestVault"], scope="files")["token"]
|
||||
assert scoped_client.get("/api/browse/Nope", headers=_hdr(key)).status_code == 403
|
||||
# Sanity : la clé pleine du même admin passe le garde vault (404 ensuite).
|
||||
full = _create(scoped_client, admin, name="full")["token"]
|
||||
assert scoped_client.get("/api/browse/Nope", headers=_hdr(full)).status_code == 404
|
||||
|
||||
def test_out_of_scope_endpoints_refused(self, scoped_client):
|
||||
admin = _login(scoped_client)
|
||||
key = _create(scoped_client, admin, vaults=["TestVault"], scope="files")["token"]
|
||||
h = _hdr(key)
|
||||
assert scoped_client.get("/api/config", headers=h).status_code == 403
|
||||
assert scoped_client.get("/api/webhooks", headers=h).status_code == 403
|
||||
assert scoped_client.get("/api/auth/tokens", headers=h).status_code == 403
|
||||
assert scoped_client.post("/api/search/replace",
|
||||
json={"query": "a", "replacement": "b", "vault": "TestVault"},
|
||||
headers=h).status_code == 403
|
||||
assert scoped_client.post("/api/vaults/add", json={"name": "X", "path": "/tmp/x"},
|
||||
headers=h).status_code == 403
|
||||
# … mais la recherche et la liste des vaults restent ouvertes.
|
||||
assert scoped_client.get("/api/search", params={"q": "test"}, headers=h).status_code == 200
|
||||
|
||||
def test_full_token_unaffected(self, scoped_client):
|
||||
"""Non-régression : une clé sans portée garde l'accès complet."""
|
||||
admin = _login(scoped_client)
|
||||
key = _create(scoped_client, admin, name="legacy")["token"]
|
||||
assert scoped_client.get("/api/config", headers=_hdr(key)).status_code == 200
|
||||
assert scoped_client.get("/api/webhooks", headers=_hdr(key)).status_code == 200
|
||||
assert scoped_client.get("/api/auth/tokens", headers=_hdr(key)).status_code == 200
|
||||
|
||||
|
||||
class TestScopedToolAccess:
|
||||
def _scoped_ctx(self):
|
||||
user = {"username": "admin", "role": "admin", "vaults": ["*"],
|
||||
"_token_vaults": ["TestVault"], "_token_scope": "files"}
|
||||
return _ctx_with_user(user)
|
||||
|
||||
def test_file_tools_allowed(self, scoped_client):
|
||||
result = call_tool("list_all_files", self._scoped_ctx(), {"vault": "TestVault"})
|
||||
assert result.ok and result.data["files"]
|
||||
|
||||
def test_other_vault_denied_at_tool_level(self, scoped_client):
|
||||
with pytest.raises(ToolPermissionError):
|
||||
call_tool("list_all_files", self._scoped_ctx(), {"vault": "Autre"})
|
||||
|
||||
def test_non_file_tools_denied(self, scoped_client):
|
||||
for tool, args in (("web_search", {"query": "x"}),
|
||||
("git_list_repos", {"provider": "gitea"}),
|
||||
("notify_external", {"title": "t", "message": "m"})):
|
||||
with pytest.raises(ToolPermissionError) as exc:
|
||||
call_tool(tool, self._scoped_ctx(), args)
|
||||
assert exc.value.code == "token_scope_denied"
|
||||
|
||||
def test_full_scope_unaffected(self, scoped_client):
|
||||
ctx = _ctx() # sans _token_scope → plein accès
|
||||
assert call_tool("list_all_files", ctx, {"vault": "TestVault"}).ok
|
||||
|
||||
|
||||
def _ctx_with_user(user):
|
||||
from backend.tools.api import ToolContext
|
||||
return ToolContext(user=user, audit_enabled=False)
|
||||
@@ -0,0 +1,106 @@
|
||||
# tests/test_sync_lists.py — #197 (Gitea #4) : réconciliation externe en un appel.
|
||||
"""Couvre l'exposition de ``mtime``/``size`` dans les listes de fichiers pour
|
||||
la synchronisation TikTak ↔ ObsiGate (un seul appel de liste suffit à
|
||||
détecter les fichiers modifiés depuis un horodatage, sans relire chaque
|
||||
fichier) :
|
||||
|
||||
- ``GET /api/vault/{vault}/paths`` : chaque entrée fichier expose ``size``,
|
||||
``mtime`` (epoch) et ``mtime_iso`` ; les répertoires portent ``None`` ;
|
||||
- ``GET /api/browse/{vault}`` : les fichiers exposent ``modified``/``modified_iso`` ;
|
||||
- outil MCP ``list_directory`` : même contrat (``size``/``mtime``/``mtime_iso``) ;
|
||||
- outil MCP ``list_all_files`` : déjà complet (``size``/``modified``) — parité vérifiée ;
|
||||
- scénario de réconciliation : snapshot → modification via l'API → re-liste,
|
||||
le fichier modifié est détecté par comparaison ``(mtime, size)``.
|
||||
"""
|
||||
from datetime import datetime
|
||||
|
||||
from backend.tools.api import call_tool
|
||||
|
||||
from tests.test_tools import _ctx
|
||||
|
||||
|
||||
def _paths(client, vault="TestVault", limit=5000):
|
||||
resp = client.get(f"/api/vault/{vault}/paths", params={"limit": limit})
|
||||
assert resp.status_code == 200, resp.text
|
||||
return resp.json()
|
||||
|
||||
|
||||
class TestPathsMetadata:
|
||||
def test_file_entries_expose_size_and_mtime(self, client):
|
||||
data = _paths(client)
|
||||
assert data["vault"] == "TestVault"
|
||||
files = [e for e in data["results"] if e["type"] == "file"]
|
||||
assert files, "le vault de test doit contenir des fichiers"
|
||||
by_path = {e["path"]: e for e in files}
|
||||
note = by_path["note1.md"]
|
||||
assert isinstance(note["size"], int) and note["size"] > 0
|
||||
assert isinstance(note["mtime"], (int, float)) and note["mtime"] > 0
|
||||
assert note["mtime_iso"]
|
||||
# L'epoch et l'ISO désignent le même instant (tolérance 2 s).
|
||||
assert abs(datetime.fromisoformat(note["mtime_iso"]).timestamp() - note["mtime"]) < 2
|
||||
|
||||
def test_directory_entries_have_null_metadata(self, client):
|
||||
data = _paths(client)
|
||||
dirs = [e for e in data["results"] if e["type"] == "directory"]
|
||||
assert dirs, "le vault de test doit contenir un répertoire (Projets)"
|
||||
for entry in dirs:
|
||||
assert entry["size"] is None
|
||||
assert entry["mtime"] is None
|
||||
assert entry["mtime_iso"] is None
|
||||
|
||||
def test_limit_still_applies(self, client):
|
||||
data = _paths(client, limit=2)
|
||||
assert data["count"] == 2
|
||||
assert len(data["results"]) == 2
|
||||
|
||||
|
||||
class TestReconciliationScenario:
|
||||
def test_single_call_detects_changed_file(self, client):
|
||||
"""Un client externe snapshot ``{path: (mtime, size)}`` puis, après
|
||||
une modification via l'API fichiers, détecte le changement en un seul
|
||||
appel de liste — sans relire aucun contenu."""
|
||||
before = {e["path"]: (e["mtime"], e["size"]) for e in _paths(client)["results"]}
|
||||
assert "note1.md" in before
|
||||
|
||||
# Modification via l'API d'écriture (même canal que la synchro TikTak).
|
||||
save = client.put(
|
||||
"/api/file/TestVault/save",
|
||||
params={"path": "note1.md"},
|
||||
json={"content": "# Introduction à Python\nContenu enrichi pour la synchro.\n"},
|
||||
)
|
||||
assert save.status_code == 200, save.text
|
||||
|
||||
after = {e["path"]: (e["mtime"], e["size"]) for e in _paths(client)["results"]}
|
||||
changed = [p for p in after if after[p] != before.get(p)]
|
||||
assert changed == ["note1.md"]
|
||||
# Le fichier non touché n'est pas signalé.
|
||||
assert "note2.md" in after and after["note2.md"] == before["note2.md"]
|
||||
|
||||
|
||||
class TestBrowseAndMcpParity:
|
||||
def test_browse_files_expose_modified(self, client):
|
||||
resp = client.get("/api/browse/TestVault")
|
||||
assert resp.status_code == 200, resp.text
|
||||
files = [i for i in resp.json()["items"] if i["type"] == "file"]
|
||||
assert files
|
||||
for item in files:
|
||||
assert isinstance(item["modified"], (int, float)) and item["modified"] > 0
|
||||
assert item["modified_iso"]
|
||||
|
||||
def test_mcp_list_directory_exposes_size_and_mtime(self, client):
|
||||
entries = call_tool("list_directory", _ctx(), {"vault": "TestVault"}).data
|
||||
files = [e for e in entries if e["type"] == "file"]
|
||||
assert files
|
||||
for entry in files:
|
||||
assert isinstance(entry["size"], int) and entry["size"] > 0
|
||||
assert isinstance(entry["mtime"], (int, float)) and entry["mtime"] > 0
|
||||
assert entry["mtime_iso"]
|
||||
|
||||
def test_mcp_list_all_files_parity(self, client):
|
||||
"""``list_all_files`` (source unique REST + MCP) expose déjà le contrat."""
|
||||
result = call_tool("list_all_files", _ctx(), {"vault": "TestVault"}).data
|
||||
assert result["files"]
|
||||
for entry in result["files"]:
|
||||
assert isinstance(entry["size"], int)
|
||||
assert isinstance(entry["modified"], (int, float))
|
||||
assert entry["modified_iso"]
|
||||
@@ -0,0 +1,160 @@
|
||||
# tests/test_webhook_filters.py — #198 (Gitea #3) : filtre vault / préfixe.
|
||||
"""Couvre :
|
||||
|
||||
- ``webhook_matches`` : abonnement, filtre ``vault`` exact, filtre
|
||||
``path_prefix`` (égalité ou sous-arbre, jamais de préfixe partiel comme
|
||||
``Docs`` vs ``Docs2``), événements de renommage (``old_path``/``new_path``) ;
|
||||
- persistance : ``create_webhook``/``update_webhook`` normalisent et exposent
|
||||
``vault``/``path_prefix`` (``""`` réinitialise le filtre) ; les webhooks
|
||||
historiques sans filtre reçoivent tout (compatibilité) ;
|
||||
- ``dispatch_webhooks`` : un webhook filtré sur le vault « TikTak » ne reçoit
|
||||
que ses événements, avec ``vault`` dans la charge ; un webhook sans filtre
|
||||
reçoit tout (client HTTP simulé, aucun réseau).
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
import backend.webhooks as wh
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def isolated_store(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(wh, "WEBHOOKS_FILE", tmp_path / "webhooks.json")
|
||||
monkeypatch.setattr(wh, "WEBHOOK_SECRETS_FILE", tmp_path / "webhook_secrets.json")
|
||||
return tmp_path
|
||||
|
||||
|
||||
def _wh(**overrides):
|
||||
base = {"id": "x", "name": "t", "url": "https://example.com/h",
|
||||
"events": ["file_created", "file_modified", "file_renamed"],
|
||||
"enabled": True}
|
||||
base.update(overrides)
|
||||
return base
|
||||
|
||||
|
||||
class TestWebhookMatches:
|
||||
def test_unsubscribed_event_never_matches(self):
|
||||
assert wh.webhook_matches(_wh(events=["file_created"]), "file_deleted",
|
||||
{"vault": "TikTak", "path": "a.md"}) is False
|
||||
|
||||
def test_no_filter_matches_everything_subscribed(self):
|
||||
assert wh.webhook_matches(_wh(), "file_created", {"vault": "TikTak", "path": "a.md"}) is True
|
||||
assert wh.webhook_matches(_wh(), "file_created", {"vault": "Autre", "path": "a.md"}) is True
|
||||
|
||||
def test_vault_filter_exact(self):
|
||||
hook = _wh(vault="TikTak")
|
||||
assert wh.webhook_matches(hook, "file_created", {"vault": "TikTak", "path": "a.md"}) is True
|
||||
assert wh.webhook_matches(hook, "file_created", {"vault": "tiktak", "path": "a.md"}) is False
|
||||
assert wh.webhook_matches(hook, "file_created", {"vault": "Autre", "path": "a.md"}) is False
|
||||
|
||||
def test_prefix_matches_subtree_only(self):
|
||||
hook = _wh(path_prefix="Maison/")
|
||||
assert wh.webhook_matches(hook, "file_created", {"vault": "V", "path": "Maison/a.md"}) is True
|
||||
assert wh.webhook_matches(hook, "file_created", {"vault": "V", "path": "Maison"}) is True
|
||||
assert wh.webhook_matches(hook, "file_created", {"vault": "V", "path": "Maison2/a.md"}) is False
|
||||
assert wh.webhook_matches(hook, "file_created", {"vault": "V", "path": "Autre/a.md"}) is False
|
||||
|
||||
def test_rename_matches_on_either_side(self):
|
||||
hook = _wh(vault="TikTak", path_prefix="Maison")
|
||||
assert wh.webhook_matches(hook, "file_renamed",
|
||||
{"vault": "TikTak", "old_path": "Maison/a.md",
|
||||
"new_path": "Maison/b.md"}) is True
|
||||
# Déplacement hors du préfixe surveillé : le consommateur est prévenu
|
||||
# (l'ancien chemin matche) pour solder sa réconciliation.
|
||||
assert wh.webhook_matches(hook, "file_renamed",
|
||||
{"vault": "TikTak", "old_path": "Maison/a.md",
|
||||
"new_path": "Autre/a.md"}) is True
|
||||
assert wh.webhook_matches(hook, "file_renamed",
|
||||
{"vault": "TikTak", "old_path": "Autre/a.md",
|
||||
"new_path": "Autre/b.md"}) is False
|
||||
|
||||
def test_combined_vault_and_prefix(self):
|
||||
hook = _wh(vault="TikTak", path_prefix="Inbox")
|
||||
assert wh.webhook_matches(hook, "file_created",
|
||||
{"vault": "TikTak", "path": "Inbox/a.md"}) is True
|
||||
assert wh.webhook_matches(hook, "file_created",
|
||||
{"vault": "TikTak", "path": "Maison/a.md"}) is False
|
||||
assert wh.webhook_matches(hook, "file_created",
|
||||
{"vault": "Autre", "path": "Inbox/a.md"}) is False
|
||||
|
||||
|
||||
class TestFilterPersistence:
|
||||
def test_create_normalizes_filters(self, isolated_store):
|
||||
hook = wh.create_webhook("T", "https://example.com/h", ["file_created"],
|
||||
vault=" TikTak ", path_prefix="/Maison/")
|
||||
assert hook["vault"] == "TikTak"
|
||||
assert hook["path_prefix"] == "Maison"
|
||||
stored = wh.get_webhooks()[0]
|
||||
assert stored["vault"] == "TikTak" and stored["path_prefix"] == "Maison"
|
||||
|
||||
def test_create_without_filters(self, isolated_store):
|
||||
hook = wh.create_webhook("T", "https://example.com/h", ["file_created"])
|
||||
assert hook["vault"] is None and hook["path_prefix"] is None
|
||||
|
||||
def test_update_clears_filter_with_empty_string(self, isolated_store):
|
||||
hook = wh.create_webhook("T", "https://example.com/h", ["file_created"],
|
||||
vault="TikTak", path_prefix="Maison")
|
||||
updated = wh.update_webhook(hook["id"], {"vault": "", "path_prefix": ""})
|
||||
assert updated["vault"] is None and updated["path_prefix"] is None
|
||||
|
||||
|
||||
class FakeResp:
|
||||
status = 200
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc):
|
||||
return False
|
||||
|
||||
|
||||
class FakeSession:
|
||||
posted: list = []
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc):
|
||||
return False
|
||||
|
||||
def post(self, url, **kwargs):
|
||||
FakeSession.posted.append({"url": url, "body": kwargs.get("data", "")})
|
||||
return FakeResp()
|
||||
|
||||
|
||||
class TestDispatchFiltering:
|
||||
def test_filtered_and_unfiltered_delivery(self, isolated_store, monkeypatch):
|
||||
monkeypatch.setattr(wh.aiohttp, "ClientSession", FakeSession)
|
||||
monkeypatch.setattr(wh, "is_safe_target", lambda url: True)
|
||||
FakeSession.posted = []
|
||||
|
||||
scoped = wh.create_webhook("tiktak", "https://tiktak.local/hook", ["file_created"],
|
||||
vault="TikTak")
|
||||
broadcast = wh.create_webhook("all", "https://all.local/hook", ["file_created"])
|
||||
|
||||
async def _run():
|
||||
await wh.dispatch_webhooks("file_created", {"vault": "TikTak", "path": "Inbox/a.md"})
|
||||
await wh.dispatch_webhooks("file_created", {"vault": "Autre", "path": "b.md"})
|
||||
# Laisser les tâches fire-and-forget s'exécuter.
|
||||
for _ in range(50):
|
||||
await asyncio.sleep(0.02)
|
||||
if len(FakeSession.posted) >= 3:
|
||||
break
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
by_url = {}
|
||||
for post in FakeSession.posted:
|
||||
by_url.setdefault(post["url"], []).append(post["body"])
|
||||
assert len(by_url.get("https://tiktak.local/hook", [])) == 1
|
||||
assert len(by_url.get("https://all.local/hook", [])) == 2
|
||||
# La charge du webhook filtré porte bien le vault.
|
||||
import json as _json
|
||||
payload = _json.loads(by_url["https://tiktak.local/hook"][0])
|
||||
assert payload["event"] == "file_created"
|
||||
assert payload["data"]["vault"] == "TikTak"
|
||||
assert scoped["id"] and broadcast["id"]
|
||||
Reference in New Issue
Block a user