Phase 2.1 : preparation au deploiement public

Rend une instance exposee sur Internet exploitable sans la transformer en
infrastructure : le produit reste local-first, sans compte et sans telemetrie.

Protection du service : limitation de debit en memoire par client et par
famille d'endpoint (health et ready jamais limites), interrupteur
SHARING_ENABLED, capacite maximale de partages, taille propre aux partages.
X-Forwarded-For n'est cru que si TRUST_PROXY_HEADERS l'autorise, et on lit alors
la derniere valeur, celle ajoutee par le proxy.

Expiration facultative des partages (24 h a 90 jours, ou jamais) : un lien
expire repond 410 et la page publique le dit, sans autre detail. Le proprietaire
peut prolonger ; aucune purge automatique.

Protection des liens non repertories : robots.txt, X-Robots-Tag et meta robots
sur /s/ et /api/, Referrer-Policy no-referrer sur tout le site, redaction des
jetons dans les journaux d'acces, share_token porte a 128 bits (les anciens
restent valides).

Schema gere par Alembic, applique au demarrage. Une base creee avant Alembic est
estampillee puis migree : verifie sur la base reelle de developpement, 4
partages inchanges et expires_at NULL.

Administration par CLI locale au conteneur (stats, liste, suppression, purge,
sauvegarde a chaud via l'API SQLite, restauration prudente) : aucune interface
/admin exposee.

Le jeton de gestion peut enfin etre exporte dans un fichier .openmind-share puis
reimporte depuis un autre navigateur, apres verification aupres du serveur. Il
n'est toujours jamais ajoute a un export .mmap.

Ajoute une page /privacy dont le texte est centralise, et corrige un bug trouve
en observant les logs : Alembic, lance dans le processus applicatif,
reappliquait la configuration de logs d'alembic.ini et desactivait les loggers
existants, ce qui faisait disparaitre les journaux d'acces.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-10 09:22:17 +02:00
parent e3c530348e
commit 49870dcd56
51 changed files with 3341 additions and 110 deletions

View File

@@ -15,24 +15,60 @@ 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"
# Les limites de debit sont desactivees par defaut dans les tests : chaque test
# qui les concerne les active explicitement via la fixture `configure`.
os.environ["SHARE_CREATE_RATE_LIMIT"] = "0"
os.environ["SHARE_READ_RATE_LIMIT"] = "0"
os.environ["SHARE_WRITE_RATE_LIMIT"] = "0"
from fastapi.testclient import TestClient # noqa: E402
from sqlalchemy import text # noqa: E402
from app.config import get_settings # noqa: E402
from app.db.base import Base # noqa: E402
from app.db.migrations import run_migrations # noqa: E402
from app.db.session import engine # noqa: E402
from app.main import app # noqa: E402
from app.services.rate_limit import limiter # noqa: E402
@pytest.fixture(autouse=True)
def _fresh_database() -> Iterator[None]:
"""Base neuve avant chaque test, construite par les vraies migrations.
On passe deliberement par Alembic plutot que par `create_all` : le schema
teste est exactement celui qu'obtiendra une installation reelle.
"""
get_settings.cache_clear()
Base.metadata.drop_all(bind=engine)
Base.metadata.create_all(bind=engine)
with engine.begin() as connection:
connection.execute(text("DROP TABLE IF EXISTS alembic_version"))
run_migrations(engine)
# Les compteurs de debit vivent dans le processus : sans cette remise a
# zero, les tests se limiteraient les uns les autres.
limiter.reset()
yield
get_settings.cache_clear()
@pytest.fixture()
def configure(monkeypatch: pytest.MonkeyPatch): # type: ignore[no-untyped-def]
"""Surcharge des reglages le temps d'un test.
Les valeurs sont posees dans l'environnement puis le cache de `get_settings`
est vide, exactement comme au demarrage de l'application.
"""
def _apply(**values: object) -> None:
for key, value in values.items():
monkeypatch.setenv(key.upper(), str(value))
get_settings.cache_clear()
yield _apply
get_settings.cache_clear()
@pytest.fixture()
def client() -> Iterator[TestClient]:
with TestClient(app) as test_client:

194
backend/tests/test_cli.py Normal file
View File

@@ -0,0 +1,194 @@
"""CLI d'administration : compteurs, listage, suppression, purge, sauvegarde."""
from __future__ import annotations
import sqlite3
from datetime import timedelta
from pathlib import Path
from fastapi.testclient import TestClient
from sqlalchemy import select
from app import cli
from app.db.session import SessionLocal
from app.models.share import SharedMap, utcnow
from tests.conftest import build_map
def _create(client: TestClient, title: str = "Carte") -> dict:
response = client.post("/api/shares", json={"title": title, "map": build_map()})
assert response.status_code == 201, response.text
return response.json()
def _expire(share_token: str) -> None:
with SessionLocal() as session:
share = session.execute(
select(SharedMap).where(SharedMap.share_token == share_token)
).scalar_one()
share.expires_at = utcnow() - timedelta(days=1)
session.commit()
def _count() -> int:
with SessionLocal() as session:
return len(session.execute(select(SharedMap)).scalars().all())
def test_stats_reports_counts_without_secrets(client: TestClient, capsys) -> None:
created = _create(client, "Infrastructure")
_expire(_create(client, "Ancienne")["share_token"])
assert cli.main(["stats"]) == 0
out = capsys.readouterr().out
assert "Partages : 2" in out
assert "actifs : 1" in out
assert "expires : 1" in out
assert "Taille" in out
# Ni jeton, ni hash, ni contenu de carte.
assert created["management_token"] not in out
assert created["share_token"] not in out
assert "map_data" not in out
def test_shares_list_masks_tokens(client: TestClient, capsys) -> None:
created = _create(client, "Infrastructure")
assert cli.main(["shares", "list"]) == 0
out = capsys.readouterr().out
assert "Infrastructure" in out
assert created["share_token"] not in out
assert created["management_token"] not in out
# Le jeton est identifiable sans etre utilisable.
assert created["share_token"][:4] in out
def test_shares_list_filters(client: TestClient, capsys) -> None:
active = _create(client, "Active")
expired = _create(client, "Expiree")
_expire(expired["share_token"])
cli.main(["shares", "list", "--active"])
out = capsys.readouterr().out
assert "Active" in out and "Expiree" not in out
cli.main(["shares", "list", "--expired"])
out = capsys.readouterr().out
assert "Expiree" in out and "Active" not in out
assert active["share_token"] not in out
def test_shares_list_limit(client: TestClient, capsys) -> None:
for index in range(5):
_create(client, f"Carte {index}")
cli.main(["shares", "list", "--limit", "2"])
body = capsys.readouterr().out.strip().splitlines()
# En-tete, separateur, puis deux lignes.
assert len(body) == 4
def test_shares_delete_requires_confirmation_flag(client: TestClient, capsys) -> None:
created = _create(client)
# Entree non interactive et pas de --yes : rien n'est supprime.
assert cli.main(["shares", "delete", created["share_token"]]) == 1
assert _count() == 1
assert cli.main(["shares", "delete", created["share_token"], "--yes"]) == 0
assert "1 partage supprime." in capsys.readouterr().out
assert _count() == 0
def test_shares_delete_unknown_token(client: TestClient, capsys) -> None:
assert cli.main(["shares", "delete", "inexistant", "--yes"]) == 1
assert "Aucun partage" in capsys.readouterr().out
def test_purge_expired_dry_run_changes_nothing(client: TestClient, capsys) -> None:
_create(client, "Active")
_expire(_create(client, "Expiree")["share_token"])
assert cli.main(["shares", "purge-expired", "--dry-run"]) == 0
out = capsys.readouterr().out
assert "1 partage(s) expire(s)" in out
assert "simulation" in out
assert _count() == 2
def test_purge_expired_removes_only_expired(client: TestClient, capsys) -> None:
_create(client, "Active")
_expire(_create(client, "Expiree")["share_token"])
assert cli.main(["shares", "purge-expired", "--yes"]) == 0
assert "1 partage(s) supprime(s)." in capsys.readouterr().out
with SessionLocal() as session:
remaining = session.execute(select(SharedMap.title)).scalars().all()
assert remaining == ["Active"]
def test_purge_without_expired_shares(client: TestClient, capsys) -> None:
_create(client)
assert cli.main(["shares", "purge-expired", "--yes"]) == 0
assert "0 partage(s) supprime(s)" in capsys.readouterr().out
def test_backup_produces_a_usable_database(client: TestClient, tmp_path: Path, capsys) -> None:
_create(client, "Infrastructure")
destination = tmp_path / "backups" / "openmind.db"
assert cli.main(["backup", str(destination)]) == 0
assert "Sauvegarde ecrite" in capsys.readouterr().out
assert destination.exists()
# La copie contient bien les donnees et se relit sans l'application.
connection = sqlite3.connect(destination)
try:
titles = [row[0] for row in connection.execute("SELECT title FROM shared_maps")]
finally:
connection.close()
assert titles == ["Infrastructure"]
def test_backup_refuses_to_overwrite_without_flag(client: TestClient, tmp_path: Path) -> None:
_create(client)
destination = tmp_path / "openmind.db"
assert cli.main(["backup", str(destination)]) == 0
assert cli.main(["backup", str(destination)]) == 1
assert cli.main(["backup", str(destination), "--yes"]) == 0
def test_restore_refuses_a_foreign_file(client: TestClient, tmp_path: Path, capsys) -> None:
intruder = tmp_path / "autre.db"
sqlite3.connect(intruder).execute("CREATE TABLE autre (id INTEGER)")
assert cli.main(["restore", str(intruder), "--yes"]) == 1
assert "shared_maps" in capsys.readouterr().out
# La base courante est intacte.
assert _count() == 0
def test_restore_puts_back_a_backup(client: TestClient, tmp_path: Path, capsys) -> None:
_create(client, "Avant sauvegarde")
backup = tmp_path / "snapshot.db"
cli.main(["backup", str(backup)])
capsys.readouterr()
# On modifie la base apres la sauvegarde.
with SessionLocal() as session:
for share in session.execute(select(SharedMap)).scalars():
session.delete(share)
session.commit()
assert _count() == 0
assert cli.main(["restore", str(backup), "--yes"]) == 0
out = capsys.readouterr().out
assert "Sauvegarde de securite" in out
assert "Restauration terminee" in out
with SessionLocal() as session:
titles = session.execute(select(SharedMap.title)).scalars().all()
assert titles == ["Avant sauvegarde"]

View File

@@ -0,0 +1,52 @@
"""Identification du client : X-Forwarded-For n'est cru que sur demande."""
from __future__ import annotations
from starlette.datastructures import Headers
from starlette.requests import Request
from app.services.client_ip import resolve_client_ip
def _request(headers: dict[str, str], peer: str | None = "203.0.113.9") -> Request:
scope = {
"type": "http",
"method": "GET",
"path": "/api/shares",
"headers": Headers(headers).raw,
"client": (peer, 12345) if peer else None,
}
return Request(scope)
def test_proxy_headers_are_ignored_by_default() -> None:
request = _request({"x-forwarded-for": "1.2.3.4"})
assert resolve_client_ip(request, trust_proxy_headers=False) == "203.0.113.9"
def test_forged_header_cannot_change_identity() -> None:
"""Sans confiance explicite, un client direct ne peut pas se demultiplier."""
seen = {
resolve_client_ip(_request({"x-forwarded-for": f"10.0.0.{i}"}), trust_proxy_headers=False)
for i in range(20)
}
assert seen == {"203.0.113.9"}
def test_trusted_proxy_uses_the_last_forwarded_value() -> None:
# Le client a forge "9.9.9.9" ; le proxy a ajoute l'adresse reellement vue.
request = _request({"x-forwarded-for": "9.9.9.9, 198.51.100.7"})
assert resolve_client_ip(request, trust_proxy_headers=True) == "198.51.100.7"
def test_trusted_proxy_falls_back_to_real_ip() -> None:
request = _request({"x-real-ip": "198.51.100.8"})
assert resolve_client_ip(request, trust_proxy_headers=True) == "198.51.100.8"
def test_falls_back_to_peer_without_headers() -> None:
assert resolve_client_ip(_request({}), trust_proxy_headers=True) == "203.0.113.9"
def test_unknown_client_is_handled() -> None:
assert resolve_client_ip(_request({}, peer=None), trust_proxy_headers=False) == "unknown"

View File

@@ -0,0 +1,147 @@
"""Expiration des partages : creation, lecture, prolongation."""
from __future__ import annotations
from datetime import timedelta
from fastapi.testclient import TestClient
from sqlalchemy import select
from app.db.session import SessionLocal
from app.models.share import SharedMap, utcnow
from tests.conftest import build_map
def _create(client: TestClient, **extra: object) -> dict:
body: dict[str, object] = {"title": "Carte", "map": build_map()}
body.update(extra)
response = client.post("/api/shares", json=body)
assert response.status_code == 201, response.text
return response.json()
def _force_expiry(share_token: str, *, delta: timedelta) -> None:
"""Deplace la date d'expiration pour ne pas attendre en test."""
with SessionLocal() as session:
share = session.execute(
select(SharedMap).where(SharedMap.share_token == share_token)
).scalar_one()
share.expires_at = utcnow() + delta
session.commit()
def test_default_expiry_is_applied(client: TestClient, configure) -> None:
configure(default_share_expiry_days=30)
body = _create(client)
assert body["expires_at"] is not None
def test_zero_default_means_permanent(client: TestClient, configure) -> None:
configure(default_share_expiry_days=0)
assert _create(client)["expires_at"] is None
def test_explicit_duration_is_used(client: TestClient, configure) -> None:
configure(default_share_expiry_days=30)
body = _create(client, expires_in_days=7)
assert body["expires_at"] is not None
public = client.get("/api/shares/" + body["share_token"]).json()
assert public["expires_at"] == body["expires_at"]
def test_permanent_can_be_requested_when_allowed(client: TestClient, configure) -> None:
configure(default_share_expiry_days=30, allow_permanent_shares=True)
assert _create(client, permanent=True)["expires_at"] is None
def test_permanent_is_refused_when_forbidden(client: TestClient, configure) -> None:
configure(allow_permanent_shares=False)
response = client.post(
"/api/shares", json={"title": "x", "map": build_map(), "permanent": True}
)
assert response.status_code == 400
assert "expiration" in response.json()["detail"].lower()
def test_expired_share_is_gone(client: TestClient, configure) -> None:
configure(default_share_expiry_days=1)
body = _create(client)
_force_expiry(body["share_token"], delta=timedelta(seconds=-1))
response = client.get("/api/shares/" + body["share_token"])
assert response.status_code == 410
assert response.json()["detail"] == "Ce partage a expire."
assert "map" not in response.json()
def test_expired_share_is_not_deleted_automatically(client: TestClient, configure) -> None:
configure(default_share_expiry_days=1)
body = _create(client)
_force_expiry(body["share_token"], delta=timedelta(seconds=-1))
client.get("/api/shares/" + body["share_token"])
with SessionLocal() as session:
assert session.execute(select(SharedMap)).scalars().all()
def test_owner_can_extend_expiry(client: TestClient, configure) -> None:
configure(default_share_expiry_days=1)
body = _create(client)
_force_expiry(body["share_token"], delta=timedelta(seconds=-1))
response = client.patch(
"/api/shares/" + body["share_token"],
json={"expires_in_days": 30},
headers={"X-Management-Token": body["management_token"]},
)
assert response.status_code == 200
assert response.json()["expires_at"] is not None
assert client.get("/api/shares/" + body["share_token"]).status_code == 200
def test_owner_can_make_share_permanent(client: TestClient, configure) -> None:
configure(default_share_expiry_days=30, allow_permanent_shares=True)
body = _create(client)
response = client.patch(
"/api/shares/" + body["share_token"],
json={"permanent": True},
headers={"X-Management-Token": body["management_token"]},
)
assert response.status_code == 200
assert response.json()["expires_at"] is None
def test_public_link_alone_cannot_change_expiry(client: TestClient) -> None:
body = _create(client)
without = client.patch("/api/shares/" + body["share_token"], json={"expires_in_days": 90})
assert without.status_code == 403
wrong = client.patch(
"/api/shares/" + body["share_token"],
json={"expires_in_days": 90},
headers={"X-Management-Token": "mauvais-jeton"},
)
assert wrong.status_code == 403
def test_management_key_can_be_verified(client: TestClient) -> None:
body = _create(client)
ok = client.post(
f"/api/shares/{body['share_token']}/verify",
headers={"X-Management-Token": body["management_token"]},
)
assert ok.status_code == 200
payload = ok.json()
assert payload["title"] == body["title"]
assert "map" not in payload
assert "management_token" not in payload
ko = client.post(
f"/api/shares/{body['share_token']}/verify",
headers={"X-Management-Token": "mauvaise-cle"},
)
assert ko.status_code == 403

View File

@@ -0,0 +1,150 @@
"""Protection des liens non repertories : indexation, referrer, journaux."""
from __future__ import annotations
import logging
from fastapi.testclient import TestClient
from app.logging_config import TokenRedactingFilter, redact_tokens
from app.services.tokens import SHARE_TOKEN_BYTES, generate_share_token
from tests.conftest import build_map
def test_robots_txt_disallows_shares(client: TestClient, tmp_path) -> None:
"""Le fichier livre au frontend interdit /s/ et /api/."""
robots = (
__import__("pathlib").Path(__file__).resolve().parents[2]
/ "frontend"
/ "public"
/ "robots.txt"
)
content = robots.read_text(encoding="utf-8")
assert "Disallow: /s/" in content
assert "Disallow: /api/" in content
def test_share_routes_are_marked_noindex(client: TestClient) -> None:
created = client.post("/api/shares", json={"title": "x", "map": build_map()}).json()
public_page = client.get(f"/s/{created['share_token']}")
assert public_page.headers["X-Robots-Tag"] == "noindex, nofollow, noarchive"
api = client.get(f"/api/shares/{created['share_token']}")
assert api.headers["X-Robots-Tag"] == "noindex, nofollow, noarchive"
def test_dashboard_is_not_marked_noindex(client: TestClient) -> None:
assert "X-Robots-Tag" not in client.get("/").headers
def test_referrer_policy_is_no_referrer(client: TestClient) -> None:
created = client.post("/api/shares", json={"title": "x", "map": build_map()}).json()
for path in ("/", f"/s/{created['share_token']}", "/api/health"):
assert client.get(path).headers["Referrer-Policy"] == "no-referrer"
def test_security_headers_are_still_present(client: TestClient) -> None:
headers = client.get("/").headers
assert headers["X-Content-Type-Options"] == "nosniff"
assert "frame-ancestors 'none'" in headers["Content-Security-Policy"]
assert headers["X-Frame-Options"] == "DENY"
assert "geolocation=()" in headers["Permissions-Policy"]
def test_share_tokens_have_at_least_128_bits() -> None:
assert SHARE_TOKEN_BYTES * 8 >= 128
token = generate_share_token()
assert len(token) >= 22
assert len({generate_share_token() for _ in range(200)}) == 200
def test_token_redaction_in_paths() -> None:
assert redact_tokens("GET /api/shares/a8F3xP9kQwZ2 HTTP/1.1") == (
"GET /api/shares/[REDACTED] HTTP/1.1"
)
assert redact_tokens("GET /s/OZGrnetO4RPbqPtS HTTP/1.1") == "GET /s/[REDACTED] HTTP/1.1"
# Les autres routes restent lisibles pour le diagnostic.
assert redact_tokens("GET /api/health HTTP/1.1") == "GET /api/health HTTP/1.1"
assert redact_tokens("GET /api/config HTTP/1.1") == "GET /api/config HTTP/1.1"
def test_redacting_filter_rewrites_arguments() -> None:
record = logging.LogRecord(
name="uvicorn.access",
level=logging.INFO,
pathname=__file__,
lineno=1,
msg='%s - "%s" %d',
args=("1.2.3.4", "GET /api/shares/SECRETTOKEN12 HTTP/1.1", 200),
exc_info=None,
)
TokenRedactingFilter().filter(record)
assert "SECRETTOKEN12" not in record.getMessage()
assert "[REDACTED]" in record.getMessage()
def test_management_token_never_appears_in_logs(client: TestClient, caplog) -> None:
with caplog.at_level(logging.DEBUG):
created = client.post("/api/shares", json={"title": "x", "map": build_map()}).json()
token = created["management_token"]
client.put(
f"/api/shares/{created['share_token']}",
json={"title": "y", "map": build_map()},
headers={"X-Management-Token": token},
)
client.put(
f"/api/shares/{created['share_token']}",
json={"title": "z", "map": build_map()},
headers={"X-Management-Token": "mauvais"},
)
# `httpx`, utilise par le client de test, journalise l'URL qu'il appelle :
# c'est un log de l'outillage de test, pas de l'application. On verifie donc
# uniquement ce qu'OpenMind ecrit lui-meme.
logged = "\n".join(
record.getMessage()
for record in caplog.records
if not record.name.startswith(("httpx", "httpcore"))
)
assert token not in logged
assert created["share_token"] not in logged
# Les messages applicatifs designent un partage par une empreinte courte.
assert "share=" in logged
def test_application_logging_survives_migrations(client: TestClient, caplog) -> None:
"""Les migrations ne doivent pas desactiver les loggers de l'application.
Alembic reconfigure les logs quand il est lance en ligne de commande. Lance
dans le processus applicatif, il couperait les journaux d'acces d'uvicorn :
la configuration embarquee doit donc laisser les loggers en place.
"""
from app.db.migrations import run_migrations
from app.db.session import engine
access_logger = logging.getLogger("uvicorn.access")
run_migrations(engine)
assert access_logger.disabled is False
with caplog.at_level(logging.INFO):
access_logger.info('%s - "%s" %d', "1.2.3.4", "GET /api/health HTTP/1.1", 200)
assert any("GET /api/health" in record.getMessage() for record in caplog.records)
def test_entry_points_are_never_cached_long() -> None:
"""index.html, sw.js et le manifeste ne doivent jamais figer une version.
Sans cela, une mise a jour de l'application resterait invisible tant que le
navigateur garde l'ancienne page en cache.
"""
from app.web import _NO_CACHE_FILES
assert {"index.html", "sw.js", "manifest.webmanifest"} <= _NO_CACHE_FILES
def test_hashed_assets_get_a_long_cache(client: TestClient) -> None:
response = client.get("/assets/index-abc123.js")
# Le fichier n'existe pas dans l'environnement de test, mais l'en-tete de
# cache est pose par le middleware, avant le service du fichier.
assert "immutable" in response.headers.get("Cache-Control", "")

View File

@@ -0,0 +1,128 @@
"""Migrations Alembic : installation neuve et reprise d'une base existante."""
from __future__ import annotations
import sqlite3
from pathlib import Path
from sqlalchemy import create_engine, inspect, text
from app.db.migrations import run_migrations
# Schema exact d'avant l'introduction d'Alembic (aucune colonne expires_at).
LEGACY_SCHEMA = """
CREATE TABLE shared_maps (
id INTEGER NOT NULL,
share_token VARCHAR(64) NOT NULL,
management_token_hash VARCHAR(64) NOT NULL,
title VARCHAR(255) NOT NULL,
map_version INTEGER NOT NULL,
map_data TEXT NOT NULL,
created_at DATETIME,
updated_at DATETIME,
PRIMARY KEY (id)
);
CREATE UNIQUE INDEX ix_shared_maps_share_token ON shared_maps (share_token);
"""
LEGACY_ROWS = [
(1, "tokenA", "hashA", "Infrastructure", 3, '{"version":3,"nodes":[]}'),
(2, "tokenB", "hashB", "Projet", 4, '{"version":4,"nodes":[{"id":"a"}]}'),
(3, "tokenC", "hashC", "Reunion", 1, '{"version":1,"nodes":[]}'),
]
def _make_legacy_database(path: Path) -> None:
connection = sqlite3.connect(path)
try:
connection.executescript(LEGACY_SCHEMA)
connection.executemany(
"INSERT INTO shared_maps "
"(id, share_token, management_token_hash, title, map_version, map_data,"
" created_at, updated_at) "
"VALUES (?, ?, ?, ?, ?, ?, '2026-01-01 10:00:00', '2026-01-02 11:00:00')",
LEGACY_ROWS,
)
connection.commit()
finally:
connection.close()
def _columns(engine) -> set[str]: # type: ignore[no-untyped-def]
with engine.connect() as connection:
return {column["name"] for column in inspect(connection).get_columns("shared_maps")}
def test_fresh_database_gets_the_full_schema(tmp_path: Path) -> None:
url = f"sqlite:///{tmp_path / 'fresh.db'}"
engine = create_engine(url, future=True)
run_migrations(engine, url)
assert "expires_at" in _columns(engine)
with engine.connect() as connection:
tables = set(inspect(connection).get_table_names())
assert {"shared_maps", "alembic_version"} <= tables
def test_migrations_are_idempotent(tmp_path: Path) -> None:
url = f"sqlite:///{tmp_path / 'twice.db'}"
engine = create_engine(url, future=True)
run_migrations(engine, url)
run_migrations(engine, url)
assert "expires_at" in _columns(engine)
def test_legacy_database_is_upgraded_without_data_loss(tmp_path: Path) -> None:
path = tmp_path / "legacy.db"
_make_legacy_database(path)
url = f"sqlite:///{path}"
engine = create_engine(url, future=True)
# Etat de depart : ancienne structure, sans table de version Alembic.
assert "expires_at" not in _columns(engine)
with engine.connect() as connection:
assert "alembic_version" not in set(inspect(connection).get_table_names())
run_migrations(engine, url)
assert "expires_at" in _columns(engine)
with engine.connect() as connection:
rows = connection.execute(
text(
"SELECT id, share_token, management_token_hash, title, map_version,"
" map_data, expires_at FROM shared_maps ORDER BY id"
)
).all()
assert len(rows) == len(LEGACY_ROWS)
for row, expected in zip(rows, LEGACY_ROWS, strict=True):
assert row.id == expected[0]
assert row.share_token == expected[1]
assert row.management_token_hash == expected[2]
assert row.title == expected[3]
assert row.map_version == expected[4]
assert row.map_data == expected[5]
# Tous les partages existants restent valides : aucune expiration.
assert row.expires_at is None
def test_legacy_database_keeps_working_after_upgrade(tmp_path: Path) -> None:
"""Apres migration, les anciennes lignes restent lisibles par l'ORM."""
path = tmp_path / "legacy2.db"
_make_legacy_database(path)
url = f"sqlite:///{path}"
engine = create_engine(url, future=True)
run_migrations(engine, url)
from sqlalchemy.orm import Session
from app.models.share import SharedMap
with Session(engine) as session:
share = session.query(SharedMap).filter_by(share_token="tokenB").one()
assert share.title == "Projet"
assert share.expires_at is None
assert share.is_expired() is False

View File

@@ -0,0 +1,157 @@
"""Protections d'une instance exposee publiquement.
Couvre la limitation de debit, la coupure du partage, la capacite maximale et
l'identification du client derriere un reverse proxy.
"""
from __future__ import annotations
from fastapi.testclient import TestClient
from app.services.rate_limit import RateLimiter, parse_rate
from tests.conftest import build_map
def _post(client: TestClient) -> int:
return client.post("/api/shares", json={"title": "x", "map": build_map()}).status_code
class TestRateRuleParsing:
def test_reads_usual_syntaxes(self) -> None:
assert parse_rate("10/hour") == parse_rate("10/h")
assert parse_rate("5/minute").limit == 5
assert parse_rate("5/minute").window_seconds == 60
assert parse_rate("2/second").window_seconds == 1
def test_missing_unit_defaults_to_minute(self) -> None:
assert parse_rate("3").window_seconds == 60
def test_disabled_values(self) -> None:
for spec in ("", "0", "off", "none", "-4/hour", "n'importe quoi"):
assert parse_rate(spec) is None
class TestRateLimiter:
def test_allows_then_blocks_within_window(self) -> None:
limiter = RateLimiter()
rule = parse_rate("2/minute")
assert limiter.check("a", rule, now=0.0) is None
assert limiter.check("a", rule, now=1.0) is None
retry = limiter.check("a", rule, now=2.0)
assert retry is not None and retry > 0
def test_window_slides(self) -> None:
limiter = RateLimiter()
rule = parse_rate("1/minute")
assert limiter.check("a", rule, now=0.0) is None
assert limiter.check("a", rule, now=10.0) is not None
assert limiter.check("a", rule, now=61.0) is None
def test_clients_are_independent(self) -> None:
limiter = RateLimiter()
rule = parse_rate("1/minute")
assert limiter.check("a", rule, now=0.0) is None
assert limiter.check("b", rule, now=0.0) is None
def test_share_creation_is_rate_limited(client: TestClient, configure) -> None:
configure(share_create_rate_limit="3/hour")
assert [_post(client) for _ in range(3)] == [201, 201, 201]
response = client.post("/api/shares", json={"title": "x", "map": build_map()})
assert response.status_code == 429
assert "Retry-After" in response.headers
assert int(response.headers["Retry-After"]) > 0
assert "detail" in response.json()
def test_health_and_ready_are_never_rate_limited(client: TestClient, configure) -> None:
configure(share_create_rate_limit="1/hour", share_read_rate_limit="1/hour")
client.post("/api/shares", json={"title": "x", "map": build_map()})
client.post("/api/shares", json={"title": "x", "map": build_map()})
for _ in range(30):
assert client.get("/api/health").status_code == 200
assert client.get("/api/ready").status_code == 200
def test_reading_a_share_is_rate_limited(client: TestClient, configure) -> None:
created = client.post("/api/shares", json={"title": "x", "map": build_map()}).json()
configure(share_read_rate_limit="2/minute")
token = created["share_token"]
assert client.get(f"/api/shares/{token}").status_code == 200
assert client.get(f"/api/shares/{token}").status_code == 200
assert client.get(f"/api/shares/{token}").status_code == 429
def test_rate_limit_can_be_disabled(client: TestClient, configure) -> None:
configure(share_create_rate_limit="0")
assert all(_post(client) == 201 for _ in range(12))
def test_sharing_disabled_blocks_the_whole_api(client: TestClient, configure) -> None:
created = client.post("/api/shares", json={"title": "x", "map": build_map()}).json()
configure(sharing_enabled=False)
expected = "Le partage est desactive sur cette instance."
assert client.post("/api/shares", json={"title": "x", "map": build_map()}).status_code == 503
assert client.get(f"/api/shares/{created['share_token']}").status_code == 503
assert (
client.put(
f"/api/shares/{created['share_token']}",
json={"title": "x", "map": build_map()},
headers={"X-Management-Token": created["management_token"]},
).status_code
== 503
)
assert (
client.delete(
f"/api/shares/{created['share_token']}",
headers={"X-Management-Token": created["management_token"]},
).status_code
== 503
)
assert (
client.post("/api/shares", json={"title": "x", "map": build_map()}).json()["detail"]
== expected
)
def test_sharing_disabled_is_announced_in_config(client: TestClient, configure) -> None:
configure(sharing_enabled=False)
assert client.get("/api/config").json()["sharingEnabled"] is False
def test_capacity_limit_refuses_new_shares(client: TestClient, configure) -> None:
configure(max_shared_maps=2)
first = client.post("/api/shares", json={"title": "1", "map": build_map()}).json()
assert client.post("/api/shares", json={"title": "2", "map": build_map()}).status_code == 201
refused = client.post("/api/shares", json={"title": "3", "map": build_map()})
assert refused.status_code == 503
assert refused.json()["detail"] == "Cette instance a atteint sa capacite maximale de partages."
# Les partages existants restent lisibles et modifiables.
assert client.get(f"/api/shares/{first['share_token']}").status_code == 200
assert (
client.put(
f"/api/shares/{first['share_token']}",
json={"title": "1 bis", "map": build_map()},
headers={"X-Management-Token": first["management_token"]},
).status_code
== 200
)
def test_capacity_zero_means_unlimited(client: TestClient, configure) -> None:
configure(max_shared_maps=0)
assert all(_post(client) == 201 for _ in range(6))
def test_share_specific_size_limit(client: TestClient, configure) -> None:
configure(max_share_size_mb=0.001)
response = client.post("/api/shares", json={"title": "x", "map": build_map(node_count=50)})
assert response.status_code == 413
assert "limite de partage" in response.json()["detail"]