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