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