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:
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", "")
|
||||
Reference in New Issue
Block a user