feat: vault DIR — exclusions par vault et lecture seule déclarable (vault Spark)
This commit is contained in:
@@ -33,7 +33,7 @@ class TestListAccessibleVaults:
|
||||
|
||||
def test_metadata_fields(self, client):
|
||||
vault = next(v for v in list_accessible_vaults(ADMIN) if v["name"] == "TestVault")
|
||||
assert set(vault) == {"name", "file_count", "tag_count", "type"}
|
||||
assert set(vault) == {"name", "file_count", "tag_count", "type", "readonly"}
|
||||
assert vault["file_count"] >= 1
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
"""Tests des vaults DIR « Spark » : exclusions par vault et lecture seule.
|
||||
|
||||
Couvre le contrat DIR_N_READONLY / DIR_N_IGNORED_DIRS / DIR_N_IGNORED_PATHS :
|
||||
- parsing des trois variables dans load_vault_config() ;
|
||||
- helper chemin_exclu (noms de dossiers et motifs fnmatch à préfixes) ;
|
||||
- navigation filtrée, lectures exclues en 404 (vue, raw, téléchargement),
|
||||
fichier non exclu lisible, indexation et list_paths filtrés ;
|
||||
- mutation refusée en 403 sur un vault déclaré readonly ;
|
||||
- /api/vaults expose readonly=true pour un DIR readonly.
|
||||
|
||||
Le vault de test est un répertoire temporaire monté en DIR (jamais un
|
||||
vault de notes), avec une arborescence qui imite la vue Spark visée :
|
||||
Console/, Workspaces/ (avec un .home), Executeurs/Hermes/{missions,data,.env}.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import ClassVar
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
VAULT_NAME = "SparkTest"
|
||||
|
||||
|
||||
def _build_spark_tree(root: Path) -> None:
|
||||
"""Crée l'arborescence technique de test sous *root*."""
|
||||
(root / "Console").mkdir(parents=True)
|
||||
(root / "Console" / "code.py").write_text('print("visible")\n', encoding="utf-8")
|
||||
(root / "Console" / "notes.md").write_text("# Notes\n", encoding="utf-8")
|
||||
|
||||
atelier = root / "Workspaces" / "atelier"
|
||||
(atelier / ".home").mkdir(parents=True)
|
||||
(atelier / "note.md").write_text("# Atelier\n", encoding="utf-8")
|
||||
(atelier / ".home" / "cache.md").write_text("# Cache du sandbox\n", encoding="utf-8")
|
||||
|
||||
venv = root / ".venv"
|
||||
venv.mkdir()
|
||||
(venv / "lib.py").write_text("# lib du venv\n", encoding="utf-8")
|
||||
|
||||
hermes = root / "Executeurs" / "Hermes"
|
||||
(hermes / "missions").mkdir(parents=True)
|
||||
(hermes / "data").mkdir(parents=True)
|
||||
(hermes / "missions" / "mission.md").write_text("# Mission\n", encoding="utf-8")
|
||||
(hermes / "data" / "config.md").write_text("# Profil de l'exécuteur\n", encoding="utf-8")
|
||||
(hermes / ".env").write_text("CLE_FICTIVE=ne-pas-exposer\n", encoding="utf-8")
|
||||
|
||||
(root / "racine.md").write_text("# Racine\n", encoding="utf-8")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def spark_dir(tmp_path: Path) -> Path:
|
||||
root = tmp_path / "SparkDir"
|
||||
root.mkdir()
|
||||
_build_spark_tree(root)
|
||||
return root
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def spark_client(spark_dir: Path):
|
||||
"""TestClient avec le répertoire de test monté en vault DIR readonly.
|
||||
|
||||
Même patron que le fixture ``app_with_vault`` de conftest.py, mais en
|
||||
DIR_N_* avec les trois nouvelles variables posées.
|
||||
"""
|
||||
os.environ["DIR_1_NAME"] = VAULT_NAME
|
||||
os.environ["DIR_1_PATH"] = str(spark_dir)
|
||||
os.environ["DIR_1_READONLY"] = "true"
|
||||
os.environ["DIR_1_IGNORED_DIRS"] = ".home,.venv"
|
||||
os.environ["DIR_1_IGNORED_PATHS"] = "Executeurs/*/data,Executeurs/*/.env"
|
||||
os.environ["OBSIGATE_AUTH_ENABLED"] = "false"
|
||||
|
||||
import backend.main
|
||||
|
||||
backend.main._load_config = lambda: {"watcher_enabled": False}
|
||||
|
||||
from backend.indexer import build_index
|
||||
from backend.main import app
|
||||
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
loop.run_until_complete(build_index())
|
||||
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
# ── Parsing de la configuration ─────────────────────────────────────
|
||||
|
||||
|
||||
class TestParsingConfig:
|
||||
def test_dir_variables_parsees(self, monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("DIR_1_NAME", "SparkParse")
|
||||
monkeypatch.setenv("DIR_1_PATH", str(tmp_path))
|
||||
monkeypatch.setenv("DIR_1_READONLY", "true")
|
||||
monkeypatch.setenv("DIR_1_IGNORED_DIRS", ".home, .venv ,__pycache__")
|
||||
monkeypatch.setenv("DIR_1_IGNORED_PATHS", "Executeurs/*/data, Executeurs/*/.env")
|
||||
|
||||
from backend.indexer import load_vault_config
|
||||
|
||||
cfg = load_vault_config()["SparkParse"]
|
||||
assert cfg["type"] == "DIR"
|
||||
assert cfg["readonly"] is True
|
||||
assert cfg["ignoredDirs"] == {".home", ".venv", "__pycache__"}
|
||||
assert cfg["ignoredPaths"] == ["Executeurs/*/data", "Executeurs/*/.env"]
|
||||
|
||||
def test_dir_sans_variables_defauts(self, monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("DIR_1_NAME", "DirNu")
|
||||
monkeypatch.setenv("DIR_1_PATH", str(tmp_path))
|
||||
|
||||
from backend.indexer import load_vault_config
|
||||
|
||||
cfg = load_vault_config()["DirNu"]
|
||||
assert cfg["readonly"] is False
|
||||
assert cfg["ignoredDirs"] == set()
|
||||
assert cfg["ignoredPaths"] == []
|
||||
|
||||
|
||||
# ── Helper chemin_exclu ─────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCheminExclu:
|
||||
CFG: ClassVar[dict] = {
|
||||
"ignoredDirs": {".home", ".venv"},
|
||||
"ignoredPaths": ["Executeurs/*/data", "Executeurs/*/.env"],
|
||||
}
|
||||
|
||||
def test_nom_de_dossier_a_toute_profondeur(self):
|
||||
from backend.services.vaults import chemin_exclu_dans_config
|
||||
|
||||
assert chemin_exclu_dans_config(self.CFG, "Workspaces/atelier/.home/cache.md")
|
||||
assert chemin_exclu_dans_config(self.CFG, ".venv/lib.py")
|
||||
assert chemin_exclu_dans_config(self.CFG, ".home")
|
||||
|
||||
def test_motif_chemin_et_sous_arbre(self):
|
||||
from backend.services.vaults import chemin_exclu_dans_config
|
||||
|
||||
assert chemin_exclu_dans_config(self.CFG, "Executeurs/Hermes/data")
|
||||
assert chemin_exclu_dans_config(self.CFG, "Executeurs/Hermes/data/config.md")
|
||||
assert chemin_exclu_dans_config(self.CFG, "Executeurs/Pi/data/x/y.py")
|
||||
assert chemin_exclu_dans_config(self.CFG, "Executeurs/Hermes/.env")
|
||||
|
||||
def test_non_exclus_et_tolerance(self):
|
||||
from backend.services.vaults import chemin_exclu_dans_config
|
||||
|
||||
assert not chemin_exclu_dans_config(self.CFG, "Console/code.py")
|
||||
assert not chemin_exclu_dans_config(self.CFG, "Executeurs/Hermes/missions/mission.md")
|
||||
assert not chemin_exclu_dans_config({}, "Console/code.py")
|
||||
assert not chemin_exclu_dans_config(None, "Console/code.py")
|
||||
assert not chemin_exclu_dans_config(self.CFG, "")
|
||||
|
||||
|
||||
# ── Navigation et lectures via l'API ────────────────────────────────
|
||||
|
||||
|
||||
class TestNavigationEtLecture:
|
||||
def test_browse_racine_cache_les_dossiers_ignores(self, spark_client):
|
||||
resp = spark_client.get(f"/api/browse/{VAULT_NAME}")
|
||||
assert resp.status_code == 200
|
||||
names = {item["name"] for item in resp.json()["items"]}
|
||||
assert {"Console", "Workspaces", "Executeurs", "racine.md"} <= names
|
||||
assert ".venv" not in names
|
||||
|
||||
def test_browse_sous_dossier_cache_data(self, spark_client):
|
||||
resp = spark_client.get(f"/api/browse/{VAULT_NAME}", params={"path": "Executeurs/Hermes"})
|
||||
assert resp.status_code == 200
|
||||
names = {item["name"] for item in resp.json()["items"]}
|
||||
assert "missions" in names
|
||||
assert "data" not in names
|
||||
|
||||
def test_browse_chemin_exclu_404(self, spark_client):
|
||||
resp = spark_client.get(f"/api/browse/{VAULT_NAME}", params={"path": "Executeurs/Hermes/data"})
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_vue_fichier_exclu_404(self, spark_client):
|
||||
resp = spark_client.get(f"/api/file/{VAULT_NAME}", params={"path": "Executeurs/Hermes/data/config.md"})
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_raw_exclu_404(self, spark_client):
|
||||
resp = spark_client.get(f"/api/file/{VAULT_NAME}/raw", params={"path": "Executeurs/Hermes/data/config.md"})
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_raw_env_exclu_404(self, spark_client):
|
||||
resp = spark_client.get(f"/api/file/{VAULT_NAME}/raw", params={"path": "Executeurs/Hermes/.env"})
|
||||
assert resp.status_code == 404
|
||||
assert "CLE_FICTIVE" not in resp.text
|
||||
|
||||
def test_telechargement_env_exclu_404(self, spark_client):
|
||||
resp = spark_client.get(f"/api/file/{VAULT_NAME}/download", params={"path": "Executeurs/Hermes/.env"})
|
||||
assert resp.status_code == 404
|
||||
assert "CLE_FICTIVE" not in resp.text
|
||||
|
||||
def test_fichier_non_exclu_lisible(self, spark_client):
|
||||
resp = spark_client.get(f"/api/file/{VAULT_NAME}/raw", params={"path": "Console/code.py"})
|
||||
assert resp.status_code == 200
|
||||
assert "visible" in resp.json()["raw"]
|
||||
|
||||
|
||||
# ── Indexation et liste des chemins ─────────────────────────────────
|
||||
|
||||
|
||||
class TestIndexation:
|
||||
def test_index_ne_contient_aucun_exclu(self, spark_client):
|
||||
from backend.indexer import get_vault_data
|
||||
|
||||
data = get_vault_data(VAULT_NAME)
|
||||
assert data is not None
|
||||
file_paths = {f["path"] for f in data.get("files", [])}
|
||||
assert "Console/code.py" in file_paths
|
||||
for p in file_paths:
|
||||
segments = p.split("/")
|
||||
assert ".home" not in segments and ".venv" not in segments
|
||||
assert "data" not in segments[:-1] or not p.startswith("Executeurs/")
|
||||
path_entries = {e["path"] for e in data.get("paths", [])}
|
||||
assert "Executeurs/Hermes/data" not in path_entries
|
||||
assert "Workspaces/atelier/.home" not in path_entries
|
||||
|
||||
def test_list_paths_filtre_les_exclus(self, spark_client):
|
||||
from backend.services.search import list_paths
|
||||
|
||||
result = list_paths(VAULT_NAME)
|
||||
paths = {r["path"] for r in result["results"]}
|
||||
assert "Console/code.py" in paths
|
||||
assert not any("/data/" in f"/{p}/" and p.startswith("Executeurs/") for p in paths)
|
||||
assert not any(".home" in p.split("/") or ".venv" in p.split("/") for p in paths)
|
||||
|
||||
|
||||
# ── Lecture seule ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestLectureSeule:
|
||||
def test_save_refuse_403(self, spark_client):
|
||||
resp = spark_client.put(
|
||||
f"/api/file/{VAULT_NAME}/save",
|
||||
params={"path": "Console/code.py"},
|
||||
json={"content": "print('modifié')\n"},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
def test_creation_dossier_refusee_403(self, spark_client):
|
||||
resp = spark_client.post(f"/api/directory/{VAULT_NAME}", json={"path": "NouveauDossier"})
|
||||
assert resp.status_code == 403
|
||||
|
||||
def test_vaults_expose_readonly(self, spark_client):
|
||||
resp = spark_client.get("/api/vaults")
|
||||
assert resp.status_code == 200
|
||||
entry = next(v for v in resp.json() if v["name"] == VAULT_NAME)
|
||||
assert entry["type"] == "DIR"
|
||||
assert entry["readonly"] is True
|
||||
Reference in New Issue
Block a user