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:
128
backend/tests/test_migrations.py
Normal file
128
backend/tests/test_migrations.py
Normal file
@@ -0,0 +1,128 @@
|
||||
"""Migrations Alembic : installation neuve et reprise d'une base existante."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import create_engine, inspect, text
|
||||
|
||||
from app.db.migrations import run_migrations
|
||||
|
||||
# Schema exact d'avant l'introduction d'Alembic (aucune colonne expires_at).
|
||||
LEGACY_SCHEMA = """
|
||||
CREATE TABLE shared_maps (
|
||||
id INTEGER NOT NULL,
|
||||
share_token VARCHAR(64) NOT NULL,
|
||||
management_token_hash VARCHAR(64) NOT NULL,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
map_version INTEGER NOT NULL,
|
||||
map_data TEXT NOT NULL,
|
||||
created_at DATETIME,
|
||||
updated_at DATETIME,
|
||||
PRIMARY KEY (id)
|
||||
);
|
||||
CREATE UNIQUE INDEX ix_shared_maps_share_token ON shared_maps (share_token);
|
||||
"""
|
||||
|
||||
LEGACY_ROWS = [
|
||||
(1, "tokenA", "hashA", "Infrastructure", 3, '{"version":3,"nodes":[]}'),
|
||||
(2, "tokenB", "hashB", "Projet", 4, '{"version":4,"nodes":[{"id":"a"}]}'),
|
||||
(3, "tokenC", "hashC", "Reunion", 1, '{"version":1,"nodes":[]}'),
|
||||
]
|
||||
|
||||
|
||||
def _make_legacy_database(path: Path) -> None:
|
||||
connection = sqlite3.connect(path)
|
||||
try:
|
||||
connection.executescript(LEGACY_SCHEMA)
|
||||
connection.executemany(
|
||||
"INSERT INTO shared_maps "
|
||||
"(id, share_token, management_token_hash, title, map_version, map_data,"
|
||||
" created_at, updated_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, '2026-01-01 10:00:00', '2026-01-02 11:00:00')",
|
||||
LEGACY_ROWS,
|
||||
)
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
|
||||
def _columns(engine) -> set[str]: # type: ignore[no-untyped-def]
|
||||
with engine.connect() as connection:
|
||||
return {column["name"] for column in inspect(connection).get_columns("shared_maps")}
|
||||
|
||||
|
||||
def test_fresh_database_gets_the_full_schema(tmp_path: Path) -> None:
|
||||
url = f"sqlite:///{tmp_path / 'fresh.db'}"
|
||||
engine = create_engine(url, future=True)
|
||||
|
||||
run_migrations(engine, url)
|
||||
|
||||
assert "expires_at" in _columns(engine)
|
||||
with engine.connect() as connection:
|
||||
tables = set(inspect(connection).get_table_names())
|
||||
assert {"shared_maps", "alembic_version"} <= tables
|
||||
|
||||
|
||||
def test_migrations_are_idempotent(tmp_path: Path) -> None:
|
||||
url = f"sqlite:///{tmp_path / 'twice.db'}"
|
||||
engine = create_engine(url, future=True)
|
||||
|
||||
run_migrations(engine, url)
|
||||
run_migrations(engine, url)
|
||||
|
||||
assert "expires_at" in _columns(engine)
|
||||
|
||||
|
||||
def test_legacy_database_is_upgraded_without_data_loss(tmp_path: Path) -> None:
|
||||
path = tmp_path / "legacy.db"
|
||||
_make_legacy_database(path)
|
||||
url = f"sqlite:///{path}"
|
||||
engine = create_engine(url, future=True)
|
||||
|
||||
# Etat de depart : ancienne structure, sans table de version Alembic.
|
||||
assert "expires_at" not in _columns(engine)
|
||||
with engine.connect() as connection:
|
||||
assert "alembic_version" not in set(inspect(connection).get_table_names())
|
||||
|
||||
run_migrations(engine, url)
|
||||
|
||||
assert "expires_at" in _columns(engine)
|
||||
with engine.connect() as connection:
|
||||
rows = connection.execute(
|
||||
text(
|
||||
"SELECT id, share_token, management_token_hash, title, map_version,"
|
||||
" map_data, expires_at FROM shared_maps ORDER BY id"
|
||||
)
|
||||
).all()
|
||||
|
||||
assert len(rows) == len(LEGACY_ROWS)
|
||||
for row, expected in zip(rows, LEGACY_ROWS, strict=True):
|
||||
assert row.id == expected[0]
|
||||
assert row.share_token == expected[1]
|
||||
assert row.management_token_hash == expected[2]
|
||||
assert row.title == expected[3]
|
||||
assert row.map_version == expected[4]
|
||||
assert row.map_data == expected[5]
|
||||
# Tous les partages existants restent valides : aucune expiration.
|
||||
assert row.expires_at is None
|
||||
|
||||
|
||||
def test_legacy_database_keeps_working_after_upgrade(tmp_path: Path) -> None:
|
||||
"""Apres migration, les anciennes lignes restent lisibles par l'ORM."""
|
||||
path = tmp_path / "legacy2.db"
|
||||
_make_legacy_database(path)
|
||||
url = f"sqlite:///{path}"
|
||||
engine = create_engine(url, future=True)
|
||||
run_migrations(engine, url)
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.share import SharedMap
|
||||
|
||||
with Session(engine) as session:
|
||||
share = session.query(SharedMap).filter_by(share_token="tokenB").one()
|
||||
assert share.title == "Projet"
|
||||
assert share.expires_at is None
|
||||
assert share.is_expired() is False
|
||||
Reference in New Issue
Block a user