- le partage EST l'autorisation: plus de 403 'Accès refusé à la vault' pour un destinataire sans accès au vault émetteur (api_file, raw, download) - chemin virtuel canonique Partage/<token>/<nom> — désambiguïse les homonymes, fallback par nom conservé - 'reçu' = partagé_avec contient l'utilisateur: un admin ne voit plus les partages des autres comme reçus (fix File not found: Partage/…) - +3 tests (14 total)
240 lines
10 KiB
Python
240 lines
10 KiB
Python
# #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-<user>/Partage/<token>/<f> 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"]
|