# #196 — Partage dirigé entre utilisateurs : gate des pages /s/*, scope de # /api/shares, révocabilité, validation des destinataires. # Fixture admin_client (conftest.py) : users admin (role admin, vaults ["*"]) # et normaluser (role user, vaults ["TestVault"]), auth activée. import os from pathlib import Path import pytest def _logged_client(username, password): """Fresh TestClient with a session (cookie) — /s/* reads the access_token cookie.""" from fastapi.testclient import TestClient from backend.main import app c = TestClient(app) resp = c.post("/api/auth/login", json={"username": username, "password": password}) assert resp.status_code == 200, resp.text return c def _ensure_shared_file(): # admin_client chdir(tmp_path) mais le vault indexé est VAULT_1_PATH # (absolu, résolu avant le chdir) → écrire là, pas dans cwd. vault = Path(os.environ["VAULT_1_PATH"]) vault.mkdir(parents=True, exist_ok=True) target = vault / "share_directed.md" target.write_text("# Partagé\n\ncontenu dirigé\n", encoding="utf-8") return "share_directed.md" @pytest.fixture def sessions(admin_client): """admin (creator) and normaluser (recipient) clients, logged in via cookie.""" return _logged_client("admin", "chab30"), _logged_client("normaluser", "normal123") class TestDirectedShareGate: def test_create_directed_share(self, sessions): admin_client, _ = sessions path = _ensure_shared_file() resp = admin_client.post("/api/share/TestVault", json={ "path": path, "shared_with": ["normaluser"], }) assert resp.status_code == 200, resp.text body = resp.json() assert body["shared_with"] == ["normaluser"] def test_create_directed_unknown_recipient_rejected(self, sessions): admin_client, _ = sessions path = _ensure_shared_file() resp = admin_client.post("/api/share/TestVault", json={ "path": path, "shared_with": ["ghost"], }) assert resp.status_code == 400 def test_recipient_can_view_but_anon_cannot(self, sessions): admin_client, user_client = sessions path = _ensure_shared_file() resp = admin_client.post("/api/share/TestVault", json={ "path": path, "shared_with": ["normaluser"], }) token = resp.json()["token"] # Anonymous → 404 (pas 401/403 : on ne fuite pas l'existence du token) from fastapi.testclient import TestClient from backend.main import app anon = TestClient(app) assert anon.get(f"/s/{token}").status_code == 404 # Recipient (cookie de session via Bearer) → 200 assert user_client.get(f"/s/{token}").status_code == 200 # Creator → 200 assert admin_client.get(f"/s/{token}").status_code == 200 def test_other_user_cannot_view(self, sessions): admin_client, _ = sessions path = _ensure_shared_file() resp = admin_client.post("/api/share/TestVault", json={ "path": path, "shared_with": ["admin"], # dirigé, mais pas normaluser }) token = resp.json()["token"] other = _logged_client("normaluser", "normal123") assert other.get(f"/s/{token}").status_code == 404 def test_public_share_still_anonymous(self, sessions): admin_client, _ = sessions path = _ensure_shared_file() resp = admin_client.post("/api/share/TestVault", json={"path": path}) token = resp.json()["token"] from fastapi.testclient import TestClient from backend.main import app anon = TestClient(app) assert anon.get(f"/s/{token}").status_code == 200 def test_shares_list_scoped_for_user(self, sessions): admin_client, user_client = sessions path = _ensure_shared_file() admin_client.post("/api/share/TestVault", json={ "path": path, "shared_with": ["normaluser"], }) # normaluser (non-admin) voit le partage reçu resp = user_client.get("/api/shares") assert resp.status_code == 200 tokens = [s["token"] for s in resp.json()] assert tokens, "destinataire doit voir le partage reçu" # Un admin voit tout resp = admin_client.get("/api/shares") assert resp.status_code == 200 assert len(resp.json()) >= 1 def test_recipient_cannot_revoke(self, sessions): admin_client, user_client = sessions path = _ensure_shared_file() resp = admin_client.post("/api/share/TestVault", json={ "path": path, "shared_with": ["normaluser"], }) sid = resp.json()["id"] resp = user_client.delete(f"/api/share/{sid}") assert resp.status_code == 403 def test_creator_can_revoke(self, sessions): admin_client, _ = sessions path = _ensure_shared_file() resp = admin_client.post("/api/share/TestVault", json={ "path": path, "shared_with": ["normaluser"], }) sid = resp.json()["id"] assert admin_client.delete(f"/api/share/{sid}").status_code == 200 def test_revoke_cuts_recipient_access(self, sessions): admin_client, user_client = sessions path = _ensure_shared_file() resp = admin_client.post("/api/share/TestVault", json={ "path": path, "shared_with": ["normaluser"], }) body = resp.json() assert user_client.get(f"/s/{body['token']}").status_code == 200 admin_client.delete(f"/api/share/{body['id']}") assert user_client.get(f"/s/{body['token']}").status_code == 404 def test_search_finds_received_share_for_recipient(self, sessions): # #196 : le contenu du document reçu est cherchable par le destinataire. admin_client, user_client = sessions path = _ensure_shared_file() resp = admin_client.post("/api/share/TestVault", json={ "path": path, "shared_with": ["normaluser"], }) token = resp.json()["token"] resp = user_client.get("/api/search", params={"q": "dirigé", "vault": "all"}) assert resp.status_code == 200 hits = [r for r in resp.json()["results"] if r.get("share_token") == token] assert hits, "le destinataire doit trouver le document reçu via la recherche" assert hits[0]["vault"] == "home-normaluser" assert hits[0]["path"].startswith("Partage/") def test_search_does_not_leak_share_to_other_user(self, sessions): # Un utilisateur non destinataire ne voit JAMAIS le contenu partagé # dans ses résultats (le vault source lui est interdit). admin_client, user_client = sessions path = _ensure_shared_file() admin_client.post("/api/share/TestVault", json={ "path": path, "shared_with": ["admin"], # dirigé à admin seul }) resp = user_client.get("/api/search", params={"q": "dirigé", "vault": "all"}) assert resp.status_code == 200 assert not [r for r in resp.json()["results"] if r.get("share_token")] def test_recipient_opens_shared_file_in_app(self, sessions): # #196 : /api/file résout home-/Partage// vers la # source — même quand le destinataire N'A PAS accès au vault source # (le partage dirigé EST l'autorisation). admin_client, user_client = sessions path = _ensure_shared_file() resp = admin_client.post("/api/share/TestVault", json={ "path": path, "shared_with": ["normaluser"], }) assert resp.status_code == 200 token = resp.json()["token"] # Retire l'accès direct au vault source : le partage doit suffire. from backend.auth.user_store import update_user update_user("normaluser", {"vaults": ["home-normaluser"]}) # Chemin canonique avec token r = user_client.get("/api/file/home-normaluser", params={"path": f"Partage/{token}/share_directed.md"}) assert r.status_code == 200, r.text body = r.json() assert body["is_markdown"] is True assert "dirigé" in body["html"] # Fallback par nom (liens sans token) r = user_client.get("/api/file/home-normaluser", params={"path": "Partage/share_directed.md"}) assert r.status_code == 200, r.text # raw + download too r = user_client.get("/api/file/home-normaluser/raw", params={"path": f"Partage/{token}/share_directed.md"}) assert r.status_code == 200 assert "dirigé" in r.json()["raw"] # Restore fixture state for other tests update_user("normaluser", {"vaults": ["TestVault"]}) def test_shared_file_resolution_is_user_scoped(self, sessions): # home-admin/Partage/x.md demandé par normaluser → pas de mapping # (le home d'un autre user lui est interdit, 403 avant tout). admin_client, user_client = sessions path = _ensure_shared_file() admin_client.post("/api/share/TestVault", json={ "path": path, "shared_with": ["normaluser"], }) r = user_client.get("/api/file/home-admin", params={"path": "Partage/share_directed.md"}) assert r.status_code == 403 def test_same_name_two_shares_token_disambiguates(self, sessions): # Deux partages de fichiers homonymes reçus : le token sélectionne # le bon, le fallback par nom prend le premier sans crash. admin_client, user_client = sessions import os as _os vault = Path(_os.environ["VAULT_1_PATH"]) (vault / "a.md").write_text("contenu alpha unique", encoding="utf-8") (vault / "b.md").write_text("contenu beta unique", encoding="utf-8") # subdirectory with same basename to force ambiguity (vault / "d1").mkdir(exist_ok=True) (vault / "d2").mkdir(exist_ok=True) (vault / "d1" / "same.md").write_text("contenu GAMMA unique", encoding="utf-8") (vault / "d2" / "same.md").write_text("contenu DELTA unique", encoding="utf-8") r1 = admin_client.post("/api/share/TestVault", json={"path": "d1/same.md", "shared_with": ["normaluser"]}) r2 = admin_client.post("/api/share/TestVault", json={"path": "d2/same.md", "shared_with": ["normaluser"]}) t1, t2 = r1.json()["token"], r2.json()["token"] r = user_client.get("/api/file/home-normaluser", params={"path": f"Partage/{t2}/same.md"}) assert r.status_code == 200 assert "DELTA" in r.json()["html"] r = user_client.get("/api/file/home-normaluser", params={"path": f"Partage/{t1}/same.md"}) assert r.status_code == 200 assert "GAMMA" in r.json()["html"]