OBS-006. Trois racines etaient baties sur os.getcwd() : le magasin de documents, les journaux et les sauvegardes. La vague G a corrige la premiere, parce qu'elle bloquait aussi OPS-011, et a laisse les deux autres. C'est la lecon deja consignee deux fois : un motif fautif corrige dans une seule couche reste dans les autres. Le plus serieux n'est pas le motif, c'est l'ecart qu'il a ouvert. backup.py gardait sa propre constante DOCUMENTS_DIR sur os.getcwd(), donc il ignorait DOCUMENTS_ROOT -- la variable que la vague G a introduite et que docs/deployment.md dit maintenant de regler pour sortir les televersements des repertoires de version. Des qu'un exploitant suit cette consigne, le script archive un repertoire ou l'application n'a jamais rien ecrit. Et comme il repond a un repertoire absent par une ligne d'information et un code de sortie 0, une tache planifiee qui surveille le code de sortie voit vert indefiniment. Autrement dit : plus l'exploitant suivait correctement la documentation de deploiement, plus surement ses sauvegardes de contrats etaient vides. Les trois racines viennent desormais d'app/storage.py, resolues a l'appel et non a l'import, et la sauvegarde imprime la source qu'elle a utilisee. Le message d'absence nomme le chemin ou elle a cherche : "No documents directory found" se lisait comme "il n'y a pas de documents" plutot que comme "je regarde au mauvais endroit". Le test qui porte est celui qui ouvre l'archive : un zip vide est un fichier de taille non nulle, donc verifier qu'un fichier a ete produit ne prouvait rien. Verifie par mutation. Co-Authored-By: Claude Opus 5 <[email protected]>
145 lines
5.5 KiB
Python
145 lines
5.5 KiB
Python
"""Where the application puts its files, and who agrees about it.
|
|
|
|
OBS-006. Three roots were built as `os.path.join(os.getcwd(), …)`: the
|
|
document store, the log directory and the backup directory. Each therefore
|
|
moved with whatever directory the process was started from — and the
|
|
processes that start this application are Waitress under Pterodactyl, a task
|
|
scheduler, pytest, and a person in a shell. Only one of the four is reliably
|
|
in the project directory.
|
|
|
|
The document store was fixed in wave G. The other two were not, and the gap
|
|
that opened between them is the reason this file exists: `backup.py` kept
|
|
archiving `./documents` while the application wrote to `DOCUMENTS_ROOT`, and
|
|
the script's answer to a missing directory is to print a line and exit 0.
|
|
Following the deployment documentation was what broke it.
|
|
"""
|
|
|
|
import os
|
|
|
|
import pytest
|
|
|
|
from app.storage import backups_root, documents_root, logs_root, project_root
|
|
|
|
|
|
class TestTheProjectAnchor:
|
|
def test_it_is_the_directory_holding_the_app_package(self):
|
|
assert os.path.isdir(os.path.join(project_root(), 'app'))
|
|
|
|
def test_it_does_not_follow_the_working_directory(self, tmp_path, monkeypatch):
|
|
"""The whole point. Every root below inherits this."""
|
|
before = project_root()
|
|
monkeypatch.chdir(tmp_path)
|
|
|
|
assert project_root() == before
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
('root', 'env_name', 'default_name'),
|
|
[
|
|
(documents_root, 'DOCUMENTS_ROOT', 'documents'),
|
|
(logs_root, 'LOG_DIR', 'logs'),
|
|
(backups_root, 'BACKUP_DIR', 'backups'),
|
|
],
|
|
)
|
|
class TestEachRoot:
|
|
def test_it_defaults_beside_the_project(self, root, env_name, default_name, monkeypatch):
|
|
monkeypatch.delenv(env_name, raising=False)
|
|
|
|
assert root() == os.path.join(project_root(), default_name)
|
|
|
|
def test_it_ignores_the_working_directory(
|
|
self, root, env_name, default_name, tmp_path, monkeypatch
|
|
):
|
|
monkeypatch.delenv(env_name, raising=False)
|
|
monkeypatch.chdir(tmp_path)
|
|
|
|
assert root() == os.path.join(project_root(), default_name)
|
|
assert not root().startswith(str(tmp_path))
|
|
|
|
def test_the_environment_wins(self, root, env_name, default_name, tmp_path, monkeypatch):
|
|
monkeypatch.setenv(env_name, str(tmp_path / 'elsewhere'))
|
|
|
|
assert root() == os.path.abspath(str(tmp_path / 'elsewhere'))
|
|
|
|
|
|
class TestTheBackupScriptAgreesWithTheApplication:
|
|
"""The regression this file was written for.
|
|
|
|
`backup.py` held its own `os.getcwd()`-based constant, so the two
|
|
answers to "where are the contracts" could differ — and did, for anyone
|
|
who set DOCUMENTS_ROOT because docs/deployment.md said to.
|
|
"""
|
|
|
|
def test_it_archives_the_directory_the_application_writes_to(self, tmp_path, monkeypatch):
|
|
from app.supporting_scripts import backup
|
|
|
|
store = tmp_path / 'somewhere-else' / 'documents'
|
|
(store / 'contrats signés').mkdir(parents=True)
|
|
(store / 'contrats signés' / 'a.pdf').write_bytes(b'%PDF-1.4 test')
|
|
monkeypatch.setenv('DOCUMENTS_ROOT', str(store))
|
|
monkeypatch.setattr(backup, 'BACKUP_DIR', str(tmp_path / 'backups'))
|
|
(tmp_path / 'backups').mkdir()
|
|
|
|
archive = backup.backup_documents()
|
|
|
|
assert archive is not None, (
|
|
'the script skipped, which is what it did on production while reporting success'
|
|
)
|
|
assert os.path.getsize(archive) > 0
|
|
|
|
def test_the_archive_actually_contains_the_contract(self, tmp_path, monkeypatch):
|
|
"""An empty zip is still a file of non-zero size. This is the
|
|
assertion that would have caught the original defect."""
|
|
import zipfile
|
|
|
|
from app.supporting_scripts import backup
|
|
|
|
store = tmp_path / 'documents'
|
|
(store / 'contrats signés').mkdir(parents=True)
|
|
(store / 'contrats signés' / 'contrat.pdf').write_bytes(b'%PDF-1.4 test')
|
|
monkeypatch.setenv('DOCUMENTS_ROOT', str(store))
|
|
monkeypatch.setattr(backup, 'BACKUP_DIR', str(tmp_path / 'backups'))
|
|
(tmp_path / 'backups').mkdir()
|
|
|
|
archive = backup.backup_documents()
|
|
|
|
with zipfile.ZipFile(archive) as zf:
|
|
assert any(name.endswith('contrat.pdf') for name in zf.namelist())
|
|
|
|
def test_a_missing_store_names_the_path_it_looked_in(self, tmp_path, monkeypatch, capsys):
|
|
""" "No documents directory found" read as "there are no documents"
|
|
rather than "I am looking in the wrong place"."""
|
|
from app.supporting_scripts import backup
|
|
|
|
missing = tmp_path / 'not-here'
|
|
monkeypatch.setenv('DOCUMENTS_ROOT', str(missing))
|
|
|
|
assert backup.backup_documents() is None
|
|
assert str(missing) in capsys.readouterr().out
|
|
|
|
|
|
class TestLogsFollowTheSameRule:
|
|
def test_configure_logging_writes_where_logs_root_says(self, tmp_path, monkeypatch):
|
|
from app.app import create_app
|
|
|
|
target = tmp_path / 'var' / 'log'
|
|
monkeypatch.setenv('LOG_DIR', str(target))
|
|
|
|
create_app(
|
|
{
|
|
'SECRET_KEY': 'test-secret-not-used-anywhere-real',
|
|
'SQLALCHEMY_DATABASE_URI': f'sqlite:///{tmp_path / "t.sqlite"}',
|
|
'TESTING': True,
|
|
'WTF_CSRF_ENABLED': False,
|
|
'FORCE_HTTPS': False,
|
|
'SESSION_COOKIE_SECURE': False,
|
|
'ENABLE_DISCORD_BOT': False,
|
|
'AUTO_CREATE_TABLES': True,
|
|
'CORS_ALLOWED_ORIGINS': '',
|
|
'RATELIMIT_ENABLED': False,
|
|
}
|
|
)
|
|
|
|
assert target.is_dir(), 'the log directory is created where LOG_DIR points'
|
|
assert (target / 'app.log').exists()
|