fix(data): supprimer un compte emporte ses contrats

DATA-012. delete_user supprimait les lignes Contract et laissait les PDF.
Des contrats nominatifs signes restaient donc sur le serveur apres la
suppression du compte, sans plus aucune reference en base : invisibles pour
l application, ingerables par elle, et toujours des donnees personnelles.

Les chemins sont lus **avant** que les lignes partent — apres, plus rien ne
dit ou sont les fichiers — et les fichiers sont retires **apres** le commit.
L ordre compte dans ce sens et pas dans l autre : un echec entre les deux
doit laisser un fichier sans ligne, ce qui est recuperable et correspond
exactement a l etat precedent, plutot qu une ligne sans fichier, qui est un
telechargement en 500 pour toujours.

Un fichier deja absent est journalise en info et ignore ; un fichier
impossible a retirer est journalise en erreur avec ce que ca implique — il
devient orphelin, donc plus rien dans l application ne proposera jamais de
le supprimer. Rien ici ne peut faire echouer la suppression du compte : le
compte est la partie que quelqu un a demandee.

Le nombre de fichiers retires part dans le journal d authentification, a
cote de account.deleted.

554 tests.
This commit is contained in:
GGThed
2026-08-11 15:34:33 -04:00
parent 70db8a7491
commit 709e8a5d51
3 changed files with 138 additions and 0 deletions
+79
View File
@@ -18,6 +18,7 @@ import os
import pytest
from app.extensions import db
from app.storage import CONTRACTS_DIR, document_path, documents_root
@@ -144,3 +145,81 @@ def _pdf():
import io
return io.BytesIO(b'%PDF-1.4\n%%EOF\n')
class TestDeletingAnAccountTakesItsDocuments:
"""DATA-012. `delete_user` removed the Contract rows and left the PDFs.
Signed, named contracts stayed on the server after the account was
deleted, with nothing in the database pointing at them: invisible to the
application, unmanageable through it, and still personal data.
"""
@pytest.fixture
def player_with_contract(self, app, client, as_role, make_user):
as_role('admin')
player_id = make_user('player')
client.post(
'/users/contracts/upload',
data={
'player_id': str(player_id),
'contract_file': (_pdf(), 'contract.pdf'),
},
content_type='multipart/form-data',
follow_redirects=True,
)
from app.models import Contract
with app.app_context():
contract = Contract.query.one()
return player_id, document_path(contract.file_path)
def test_the_file_goes_with_the_account(self, app, client, player_with_contract):
player_id, path = player_with_contract
assert os.path.exists(path), 'the fixture never wrote the file'
client.post(f'/users/{player_id}/delete', follow_redirects=True)
from app.models import Contract
with app.app_context():
assert Contract.query.count() == 0
assert not os.path.exists(path), 'the row went and the PDF stayed'
def test_a_file_already_gone_does_not_stop_the_deletion(
self, app, client, player_with_contract
):
"""Removing a file must never be able to abort the removal of an
account: the account is the part somebody asked for."""
player_id, path = player_with_contract
os.remove(path)
response = client.post(f'/users/{player_id}/delete', follow_redirects=True)
assert response.status_code == 200
from app.models import User
with app.app_context():
assert db.session.get(User, player_id) is None
class TestDiscardDocuments:
def test_it_reports_how_many_it_removed(self, tmp_path, monkeypatch):
from app.storage import discard_documents
monkeypatch.setenv('DOCUMENTS_ROOT', str(tmp_path))
(tmp_path / 'a.pdf').write_bytes(b'%PDF-')
(tmp_path / 'b.pdf').write_bytes(b'%PDF-')
assert discard_documents(['a.pdf', 'b.pdf', 'never-existed.pdf']) == 2
def test_a_none_path_is_skipped(self, tmp_path, monkeypatch):
"""signed_file_path is NULL until the player signs, and both paths of
every contract are passed in together."""
from app.storage import discard_documents
monkeypatch.setenv('DOCUMENTS_ROOT', str(tmp_path))
assert discard_documents([None, '']) == 0