fix(ops): sauvegarder reellement la base PostgreSQL

DATA-002 / OPS-001. backup.py ciblait SQLite : import sqlite3, DATABASE_PATH
par defaut instance/team_tryouts.db, et l'API de sauvegarde sqlite3. La
production tourne sur PostgreSQL, donc le fichier n'existait pas. Le script
affichait "[WARNING] Database not found... Skipping database backup" -- puis,
main() ne suivant que le resultat de la verification, **sortait avec le code
0**. Toute tache planifiee surveillant le code de sortie voyait vert alors
qu'aucune sauvegarde n'avait jamais ete produite.

Il n'existait donc aucune sauvegarde applicative de la base.

Reecriture
  pg_dump en --format=custom : compresse, et pg_restore permet une
  restauration selective, ce qu'un dump SQL a plat ne permet pas.
  parse_database_url accepte les suffixes de dialecte SQLAlchemy
  (postgresql+psycopg://) que pg_dump ne comprend pas, et refuse
  explicitement une URL SQLite -- le cas exact qui passait en silence.

  Le mot de passe ne figure jamais dans la ligne de commande : il serait
  visible de tout processus capable de lister argv. Il passe par PGPASSWORD.
  Il est egalement absent des messages affiches, qui atterrissent dans les
  journaux du planificateur.

  verify_backup lit l'archive avec pg_restore --list et exige au moins une
  table : une archive illisible ne se restaure pas, et une archive sans
  table signifie que le dump a vise la mauvaise cible. Les deux sont des
  echecs silencieux qu'il vaut mieux attraper maintenant que pendant un
  incident.

  Le code de sortie vaut 0 uniquement si le dump a ete produit ET verifie.

L'archive des documents est conservee : les contrats signes n'existent que
sur disque, la base ne stocke que des chemins. Restaurer l'une sans l'autre
laisse des lignes pointant vers des fichiers absents.

docs/database-restore.md
  Procedure de restauration testable sur une base jetable, requetes de
  controle, demarrage de l'application sur la copie restauree, plan de
  reprise par scenario. ENABLE_DISCORD_BOT=false y est signale comme non
  optionnel : sans lui, l'exercice demarre un vrai bot et envoie de vraies
  notifications a de vraies personnes, a partir de donnees restaurees.

  Les points ouverts sont listes tels quels : aucune copie hors site, pas de
  chiffrement au repos, aucune planification, et l'exercice de restauration
  n'a jamais ete effectue.

17 tests sur ce qui est verifiable sans serveur PostgreSQL : analyse de
l'URL, construction de la commande, non-fuite du mot de passe, et surtout
codes de sortie -- le silence ne vaut plus succes.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
GGThed
2026-08-07 20:06:51 -04:00
co-authored by Claude Opus 5
parent ca45be80db
commit 7bf428f9a0
3 changed files with 551 additions and 108 deletions
+123
View File
@@ -0,0 +1,123 @@
"""Backup script.
DATA-002 / OPS-001. The previous script targeted SQLite while production
runs on PostgreSQL: it printed "[WARNING] Database not found" and still
exited 0, so a scheduled task watching the exit code saw green while
nothing had ever been backed up.
These tests cover what can be checked without a PostgreSQL server: URL
parsing, command construction, password handling, and — above all — that
failure now produces a non-zero exit code.
"""
import pytest
from app.supporting_scripts import backup as backup_module
from app.supporting_scripts.backup import (
BackupError,
build_dump_command,
describe_target,
dump_environment,
parse_database_url,
)
PASSWORD = 'sup3r-s3cret'
URL = f'postgresql://appuser:{PASSWORD}@db.example.test:6432/tryouts'
class TestUrlParsing:
def test_a_standard_url_is_split(self):
conn = parse_database_url(URL)
assert conn['host'] == 'db.example.test'
assert conn['port'] == '6432'
assert conn['dbname'] == 'tryouts'
assert conn['user'] == 'appuser'
assert conn['password'] == PASSWORD
def test_the_sqlalchemy_dialect_suffix_is_accepted(self):
"""SQLAlchemy writes postgresql+psycopg://, which pg_dump rejects."""
conn = parse_database_url(
'postgresql+psycopg://u:p@localhost/tryouts')
assert conn['dbname'] == 'tryouts'
def test_the_default_port_is_applied(self):
assert parse_database_url('postgresql://u:p@localhost/db')['port'] == '5432'
def test_percent_encoded_credentials_are_decoded(self):
conn = parse_database_url('postgresql://u%40corp:p%23ass@h/db')
assert conn['user'] == 'u@corp'
assert conn['password'] == 'p#ass'
def test_a_missing_url_is_refused(self):
with pytest.raises(BackupError, match='DATABASE_URL is not set'):
parse_database_url(None)
def test_a_sqlite_url_is_refused(self):
"""The exact case that used to pass silently."""
with pytest.raises(BackupError, match='not a PostgreSQL'):
parse_database_url('sqlite:///instance/team_tryouts.db')
def test_a_url_without_a_database_name_is_refused(self):
with pytest.raises(BackupError, match='does not name a database'):
parse_database_url('postgresql://u:p@localhost/')
class TestCommandConstruction:
def test_the_password_never_reaches_the_command_line(self):
"""Anything on argv is visible to any process listing."""
command = build_dump_command(parse_database_url(URL), '/tmp/out.dump')
assert PASSWORD not in ' '.join(command)
def test_the_password_travels_through_the_environment(self):
env = dump_environment(parse_database_url(URL))
assert env['PGPASSWORD'] == PASSWORD
def test_no_pgpassword_is_set_when_there_is_no_password(self):
env = dump_environment(parse_database_url('postgresql://u@localhost/db'))
assert 'PGPASSWORD' not in env
def test_the_dump_is_written_in_the_custom_format(self):
"""Custom format is compressed and restorable selectively."""
command = build_dump_command(parse_database_url(URL), '/tmp/out.dump')
assert '--format=custom' in command
assert '--file' in command
assert command[command.index('--file') + 1] == '/tmp/out.dump'
def test_connection_settings_are_passed_through(self):
command = build_dump_command(parse_database_url(URL), '/tmp/out.dump')
assert command[command.index('--host') + 1] == 'db.example.test'
assert command[command.index('--port') + 1] == '6432'
assert command[command.index('--dbname') + 1] == 'tryouts'
def test_the_description_omits_the_password(self):
"""It is printed on stdout and lands in scheduler logs."""
described = describe_target(parse_database_url(URL))
assert PASSWORD not in described
assert 'db.example.test:6432/tryouts' in described
class TestExitCodes:
"""The regression that matters: silence is no longer success."""
def test_a_sqlite_url_fails_the_run(self, monkeypatch, capsys):
monkeypatch.setenv('DATABASE_URL', 'sqlite:///instance/team_tryouts.db')
assert backup_module.main([]) == 1
assert 'FAILED' in capsys.readouterr().out
def test_a_missing_url_fails_the_run(self, monkeypatch):
monkeypatch.delenv('DATABASE_URL', raising=False)
assert backup_module.main([]) == 1
def test_a_missing_pg_dump_fails_the_run(self, monkeypatch, tmp_path):
monkeypatch.setenv('DATABASE_URL', URL)
monkeypatch.setattr(backup_module, 'BACKUP_DIR', str(tmp_path))
monkeypatch.setattr(backup_module, 'PG_DUMP', 'pg_dump_that_does_not_exist')
assert backup_module.main([]) == 1
def test_verifying_a_missing_archive_fails(self, tmp_path):
assert backup_module.main(
['--verify-only', str(tmp_path / 'nope.dump')]) == 1