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:
@@ -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
194
backend/tests/test_cli.py
Normal 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"]
|
||||
52
backend/tests/test_client_ip.py
Normal file
52
backend/tests/test_client_ip.py
Normal 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"
|
||||
147
backend/tests/test_expiry.py
Normal file
147
backend/tests/test_expiry.py
Normal 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
|
||||
150
backend/tests/test_link_privacy.py
Normal file
150
backend/tests/test_link_privacy.py
Normal 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", "")
|
||||
128
backend/tests/test_migrations.py
Normal file
128
backend/tests/test_migrations.py
Normal 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
|
||||
157
backend/tests/test_public_exposure.py
Normal file
157
backend/tests/test_public_exposure.py
Normal 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"]
|
||||
Reference in New Issue
Block a user