Files
openmind/backend/tests/test_cli.py
snoopy 9ede2c5e68 Phase 2.1.1 : durcissement avant exposition publique
Fail-fast sur la configuration. Une limite de debit illisible (10/hours,
10/banana, -1/hour) arrete desormais le demarrage avec un message nommant la
variable, au lieu de desactiver silencieusement la protection : seules les
valeurs 0, off, none, disabled et vide desactivent volontairement. Meme
traitement pour MAX_SHARED_MAPS, MAX_SHARE_SIZE_MB, DEFAULT_SHARE_EXPIRY_DAYS
et chaque entree de TRUSTED_PROXY_CIDRS.

X-Forwarded-For n'est plus cru sur la seule foi de TRUST_PROXY_HEADERS : la
connexion doit aussi provenir d'un reseau declare dans TRUSTED_PROXY_CIDRS
(ipaddress, IPv4/IPv6/CIDR). Activer la confiance sans declarer de reseau est
refuse au demarrage, faute de quoi la configuration semblerait active sans
l'etre. L'adresse retenue est normalisee : les differentes ecritures d'une meme
IPv6 ne donnent plus des compteurs distincts.

MAX_SHARED_MAPS : comptage et insertion serialises par un verrou de processus,
pour que deux creations simultanees ne franchissent pas ensemble la derniere
place. Documente comme valable pour un service applicatif unique.

Ajoute APP_BIND pour ne pas publier 8080 sur toutes les interfaces en
production, une option backup --keep pour la retention, et documente Nginx,
Traefik en override, cron et systemd.

Corrige un bug de rendu du build local : mon edition avait reecrit
docker-entrypoint.sh en CRLF, rendant le shebang introuvable dans le conteneur.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 09:53:29 +02:00

231 lines
8.0 KiB
Python

"""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"]
def test_backup_retention_keeps_the_most_recent(client: TestClient, tmp_path: Path, capsys) -> None:
"""--keep N ne conserve que les N sauvegardes les plus recentes du dossier."""
import os
import time
_create(client, "Infrastructure")
folder = tmp_path / "backups"
for index in range(5):
destination = folder / f"openmind-{index}.db"
assert cli.main(["backup", str(destination), "--yes"]) == 0
# Horodatage distinct : le tri de retention doit etre deterministe.
os.utime(destination, (time.time() + index, time.time() + index))
capsys.readouterr()
latest = folder / "openmind-latest.db"
assert cli.main(["backup", str(latest), "--keep", "3"]) == 0
out = capsys.readouterr().out
assert "Ancienne sauvegarde supprimee" in out
remaining = sorted(path.name for path in folder.glob("*.db"))
assert len(remaining) == 3
# La sauvegarde qui vient d'etre ecrite est toujours conservee, et compte
# dans les trois : on ne finit jamais avec une de plus que demande.
assert "openmind-latest.db" in remaining
def test_backup_without_keep_deletes_nothing(client: TestClient, tmp_path: Path) -> None:
_create(client)
folder = tmp_path / "backups"
for index in range(3):
assert cli.main(["backup", str(folder / f"openmind-{index}.db")]) == 0
assert cli.main(["backup", str(folder / "openmind-3.db")]) == 0
assert len(list(folder.glob("*.db"))) == 4