OpenMind v1 : cartes mentales libres, local-first et dockerisees
Premiere version fonctionnelle, demarrable avec `docker compose up -d --build` sur http://localhost:8080. Architecture - Frontend React 18 / TypeScript strict / Vite / Tailwind, canvas @xyflow/react, etat Zustand, persistance IndexedDB via Dexie, auto-layout dagre. - Backend FastAPI / SQLAlchemy 2 / Pydantic v2 / SQLite, qui sert aussi les fichiers statiques du frontend compile. - Image Docker multi-stage, un seul service, bind mount ./data pour SQLite. Local-first Les cartes restent dans le navigateur. Le serveur ne recoit une carte que sur demande explicite de partage. Partage Deux secrets distincts : share_token public en lecture seule dans l'URL, et management_token conserve uniquement cote client, transmis en en-tete pour les modifications et stocke hashe en base. Fonctionnalites Tableau de bord, editeur complet (undo/redo, copier/coller, raccourcis, branches repliables, recherche), 9 themes, 4 dispositions automatiques, panneau de proprietes, import/export .mmap, export PNG/SVG/PDF, impression, page publique en lecture seule, PWA. Qualite 25 tests pytest et 43 tests vitest au vert, ruff et ESLint sans erreur, TypeScript strict sans erreur. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
0
backend/tests/__init__.py
Normal file
0
backend/tests/__init__.py
Normal file
84
backend/tests/conftest.py
Normal file
84
backend/tests/conftest.py
Normal file
@@ -0,0 +1,84 @@
|
||||
"""Fixtures de test : base SQLite temporaire, isolee du volume de production."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
from collections.abc import Iterator
|
||||
|
||||
import pytest
|
||||
|
||||
# Doit etre defini AVANT le premier import de l'application.
|
||||
_TMP_DIR = tempfile.mkdtemp(prefix="openmind-tests-")
|
||||
os.environ["DATABASE_URL"] = f"sqlite:///{_TMP_DIR}/test.db"
|
||||
os.environ["STATIC_DIR"] = f"{_TMP_DIR}/static"
|
||||
os.environ["APP_NAME"] = "OpenMind"
|
||||
os.environ["MAX_NODES"] = "5000"
|
||||
os.environ["MAX_MAP_SIZE_MB"] = "10"
|
||||
|
||||
from fastapi.testclient import TestClient # noqa: E402
|
||||
|
||||
from app.config import get_settings # noqa: E402
|
||||
from app.db.base import Base # noqa: E402
|
||||
from app.db.session import engine # noqa: E402
|
||||
from app.main import app # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _fresh_database() -> Iterator[None]:
|
||||
get_settings.cache_clear()
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
yield
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client() -> Iterator[TestClient]:
|
||||
with TestClient(app) as test_client:
|
||||
yield test_client
|
||||
|
||||
|
||||
def build_map(node_count: int = 3, name: str = "Carte de test") -> dict:
|
||||
"""Construit un document carte minimal mais valide."""
|
||||
nodes = [
|
||||
{
|
||||
"id": "root",
|
||||
"type": "root",
|
||||
"text": name,
|
||||
"position": {"x": 0, "y": 0},
|
||||
"parentId": None,
|
||||
}
|
||||
]
|
||||
edges = []
|
||||
for index in range(1, node_count):
|
||||
node_id = f"n{index}"
|
||||
nodes.append(
|
||||
{
|
||||
"id": node_id,
|
||||
"type": "standard",
|
||||
"text": f"Noeud {index}",
|
||||
"position": {"x": 200, "y": index * 80},
|
||||
"parentId": "root",
|
||||
}
|
||||
)
|
||||
edges.append({"id": f"e-root-{node_id}", "source": "root", "target": node_id})
|
||||
|
||||
return {
|
||||
"version": 1,
|
||||
"id": "11111111-1111-4111-8111-111111111111",
|
||||
"name": name,
|
||||
"createdAt": "2026-01-01T00:00:00.000Z",
|
||||
"updatedAt": "2026-01-01T00:00:00.000Z",
|
||||
"nodes": nodes,
|
||||
"edges": edges,
|
||||
"viewport": {"x": 0, "y": 0, "zoom": 1},
|
||||
"settings": {"theme": "default", "layout": "mindmap-right", "branchStyle": "bezier"},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def created_share(client: TestClient) -> dict:
|
||||
response = client.post("/api/shares", json={"title": "Carte de test", "map": build_map()})
|
||||
assert response.status_code == 201, response.text
|
||||
return response.json()
|
||||
28
backend/tests/test_health.py
Normal file
28
backend/tests/test_health.py
Normal file
@@ -0,0 +1,28 @@
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
def test_health_returns_ok(client: TestClient) -> None:
|
||||
response = client.get("/api/health")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "ok"
|
||||
|
||||
|
||||
def test_public_config_exposes_no_secret(client: TestClient) -> None:
|
||||
response = client.get("/api/config")
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["appName"]
|
||||
assert body["maxNodes"] > 0
|
||||
assert "management" not in response.text.lower()
|
||||
|
||||
|
||||
def test_security_headers_present(client: TestClient) -> None:
|
||||
response = client.get("/api/health")
|
||||
assert response.headers["X-Content-Type-Options"] == "nosniff"
|
||||
assert "default-src" in response.headers["Content-Security-Policy"]
|
||||
|
||||
|
||||
def test_unknown_api_route_returns_clean_404(client: TestClient) -> None:
|
||||
response = client.get("/api/does-not-exist")
|
||||
assert response.status_code == 404
|
||||
assert response.json()["detail"]
|
||||
109
backend/tests/test_shares.py
Normal file
109
backend/tests/test_shares.py
Normal file
@@ -0,0 +1,109 @@
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from tests.conftest import build_map
|
||||
|
||||
|
||||
def test_create_share_returns_two_distinct_tokens(client: TestClient) -> None:
|
||||
response = client.post("/api/shares", json={"title": "Homelab", "map": build_map()})
|
||||
assert response.status_code == 201, response.text
|
||||
body = response.json()
|
||||
|
||||
assert body["share_token"]
|
||||
assert body["management_token"]
|
||||
assert body["share_token"] != body["management_token"]
|
||||
# Entropie suffisante : token_urlsafe(12) et token_urlsafe(32).
|
||||
assert len(body["share_token"]) >= 16
|
||||
assert len(body["management_token"]) >= 40
|
||||
assert body["share_path"] == "/s/" + body["share_token"]
|
||||
|
||||
|
||||
def test_read_share_returns_map_without_management_token(
|
||||
client: TestClient, created_share: dict
|
||||
) -> None:
|
||||
response = client.get("/api/shares/" + created_share["share_token"])
|
||||
assert response.status_code == 200
|
||||
|
||||
body = response.json()
|
||||
assert body["title"] == "Carte de test"
|
||||
assert body["map_version"] == 1
|
||||
assert len(body["map"]["nodes"]) == 3
|
||||
assert "management_token" not in body
|
||||
assert created_share["management_token"] not in response.text
|
||||
|
||||
|
||||
def test_read_unknown_share_returns_404(client: TestClient) -> None:
|
||||
response = client.get("/api/shares/inexistant-token")
|
||||
assert response.status_code == 404
|
||||
assert "existe pas" in response.json()["detail"]
|
||||
|
||||
|
||||
def test_update_share_with_valid_management_token(client: TestClient, created_share: dict) -> None:
|
||||
updated_map = build_map(node_count=6, name="Homelab v2")
|
||||
response = client.put(
|
||||
"/api/shares/" + created_share["share_token"],
|
||||
json={"title": "Homelab v2", "map": updated_map},
|
||||
headers={"X-Management-Token": created_share["management_token"]},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["title"] == "Homelab v2"
|
||||
|
||||
public = client.get("/api/shares/" + created_share["share_token"]).json()
|
||||
assert len(public["map"]["nodes"]) == 6
|
||||
assert public["title"] == "Homelab v2"
|
||||
|
||||
|
||||
def test_update_share_with_wrong_management_token_is_refused(
|
||||
client: TestClient, created_share: dict
|
||||
) -> None:
|
||||
response = client.put(
|
||||
"/api/shares/" + created_share["share_token"],
|
||||
json={"title": "Pirate", "map": build_map()},
|
||||
headers={"X-Management-Token": "mauvais-jeton"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
public = client.get("/api/shares/" + created_share["share_token"]).json()
|
||||
assert public["title"] == "Carte de test"
|
||||
|
||||
|
||||
def test_update_share_without_management_token_is_refused(
|
||||
client: TestClient, created_share: dict
|
||||
) -> None:
|
||||
response = client.put(
|
||||
"/api/shares/" + created_share["share_token"],
|
||||
json={"title": "Pirate", "map": build_map()},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_share_token_alone_cannot_delete(client: TestClient, created_share: dict) -> None:
|
||||
response = client.delete(
|
||||
"/api/shares/" + created_share["share_token"],
|
||||
headers={"X-Management-Token": created_share["share_token"]},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert client.get("/api/shares/" + created_share["share_token"]).status_code == 200
|
||||
|
||||
|
||||
def test_delete_share_then_read_returns_404(client: TestClient, created_share: dict) -> None:
|
||||
response = client.delete(
|
||||
"/api/shares/" + created_share["share_token"],
|
||||
headers={"X-Management-Token": created_share["management_token"]},
|
||||
)
|
||||
assert response.status_code == 204
|
||||
assert client.get("/api/shares/" + created_share["share_token"]).status_code == 404
|
||||
|
||||
|
||||
def test_delete_unknown_share_returns_404(client: TestClient) -> None:
|
||||
response = client.delete(
|
||||
"/api/shares/inexistant", headers={"X-Management-Token": "peu-importe"}
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_share_tokens_are_unique(client: TestClient) -> None:
|
||||
tokens = set()
|
||||
for _ in range(15):
|
||||
body = client.post("/api/shares", json={"title": "x", "map": build_map()}).json()
|
||||
tokens.add(body["share_token"])
|
||||
assert len(tokens) == 15
|
||||
22
backend/tests/test_spa.py
Normal file
22
backend/tests/test_spa.py
Normal file
@@ -0,0 +1,22 @@
|
||||
"""Le routage cote client doit rester fonctionnel derriere FastAPI."""
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
def test_share_route_falls_back_to_spa(client: TestClient) -> None:
|
||||
response = client.get("/s/un-token-quelconque")
|
||||
assert response.status_code == 200
|
||||
assert "text/html" in response.headers["content-type"]
|
||||
|
||||
|
||||
def test_root_route_is_served(client: TestClient) -> None:
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
assert "text/html" in response.headers["content-type"]
|
||||
|
||||
|
||||
def test_path_traversal_is_blocked(client: TestClient) -> None:
|
||||
response = client.get("/../../etc/passwd")
|
||||
# Soit 404, soit repli SPA : dans tous les cas jamais le contenu du fichier.
|
||||
assert response.status_code in (200, 404)
|
||||
assert "root:" not in response.text
|
||||
78
backend/tests/test_validation.py
Normal file
78
backend/tests/test_validation.py
Normal file
@@ -0,0 +1,78 @@
|
||||
"""Tests des garde-fous : payload invalide, limites de taille et de noeuds."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.config import get_settings
|
||||
from tests.conftest import build_map
|
||||
|
||||
|
||||
def test_missing_map_returns_422(client: TestClient) -> None:
|
||||
response = client.post("/api/shares", json={"title": "Sans carte"})
|
||||
assert response.status_code == 422
|
||||
assert "carte valide" in response.json()["detail"]
|
||||
|
||||
|
||||
def test_map_without_version_returns_422(client: TestClient) -> None:
|
||||
payload = build_map()
|
||||
del payload["version"]
|
||||
response = client.post("/api/shares", json={"title": "x", "map": payload})
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
def test_node_without_id_returns_422(client: TestClient) -> None:
|
||||
payload = build_map()
|
||||
del payload["nodes"][0]["id"]
|
||||
response = client.post("/api/shares", json={"title": "x", "map": payload})
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
def test_unknown_top_level_field_is_rejected(client: TestClient) -> None:
|
||||
response = client.post(
|
||||
"/api/shares", json={"title": "x", "map": build_map(), "is_premium": True}
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
def test_garbage_body_returns_422(client: TestClient) -> None:
|
||||
response = client.post("/api/shares", json={"map": "ceci n est pas une carte"})
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
def test_node_count_limit_is_enforced(client: TestClient) -> None:
|
||||
os.environ["MAX_NODES"] = "10"
|
||||
get_settings.cache_clear()
|
||||
try:
|
||||
response = client.post("/api/shares", json={"title": "x", "map": build_map(node_count=25)})
|
||||
assert response.status_code == 422
|
||||
assert "10" in response.json()["detail"]
|
||||
finally:
|
||||
os.environ["MAX_NODES"] = "5000"
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_map_size_limit_is_enforced(client: TestClient) -> None:
|
||||
os.environ["MAX_MAP_SIZE_MB"] = "0.001"
|
||||
get_settings.cache_clear()
|
||||
try:
|
||||
response = client.post("/api/shares", json={"title": "x", "map": build_map(node_count=60)})
|
||||
assert response.status_code == 413
|
||||
assert "volumineuse" in response.json()["detail"] or "limite" in response.json()["detail"]
|
||||
finally:
|
||||
os.environ["MAX_MAP_SIZE_MB"] = "10"
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_node_text_is_stored_as_plain_text(client: TestClient) -> None:
|
||||
"""Le backend stocke le texte tel quel : il ne l interprete jamais comme du HTML."""
|
||||
payload = build_map()
|
||||
payload["nodes"][0]["text"] = "<script>alert(1)</script>"
|
||||
created = client.post("/api/shares", json={"title": "x", "map": payload})
|
||||
assert created.status_code == 201
|
||||
|
||||
public = client.get("/api/shares/" + created.json()["share_token"]).json()
|
||||
assert public["map"]["nodes"][0]["text"] == "<script>alert(1)</script>"
|
||||
assert public["map"]["nodes"][0]["type"] == "root"
|
||||
Reference in New Issue
Block a user