Files
openmind/backend/tests/test_public_exposure.py
snoopy 49870dcd56 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>
2026-09-10 09:22:17 +02:00

158 lines
6.0 KiB
Python

"""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"]