Files
openmind/backend/tests/test_link_privacy.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

151 lines
5.8 KiB
Python

"""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", "")