erplibre/test/test_mail_tui_account.py
Mathieu Benoit cfbdd9406e [REF] format : passer l'outillage et les tests sous ruff
Le formateur de ce dépôt est ruff depuis qu'il remplace black, qui ne connaît
aucune cible au-delà de py313 ; ce passage applique sa norme à l'arbre entier,
d'un coup, pour qu'aucun commit de fond n'ait à porter du style. L'écart tient
presque entièrement aux chaînes coupées à la main que ruff recolle quand elles
tiennent sur une ligne, et aux « with » multiples qu'il regroupe : aucune
valeur ne change, et les clés de traduction non plus.
Vérifié : la suite unitaire reste verte après le passage, et le contrôle de
syntaxe ne signale rien.

--- EN ---

This repository's formatter is ruff since it replaced black, which knows no
target beyond py313; this pass applies its standard to the whole tree at once,
so that no substantive commit has to carry style. The difference is almost
entirely the hand-split strings ruff joins back when they fit on one line, and
the multiple "with" it merges: no value changes, nor do the translation keys.
Checked: the unit suite stays green after the pass, and the syntax check
reports nothing.

Assisted-by: Claude Opus 5
2026-09-24 14:40:38 -04:00

880 lines
34 KiB
Python

#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Ajout de compte depuis le TUI : `n` (ou le nœud "+ Ajouter un compte")
ouvre le formulaire, et un compte sauvegardé doit devenir utilisable sans
redémarrer — présent à la fois dans `self.sessions` ET dans l'arbre.
Comme `test_mail_compose.py` : `on_mount` lit `todo_prefs`, qui crée
`~/.erplibre` s'il est absent, et l'écran d'ajout lit/écrit
`~/.erplibre/mail/accounts.json` par les mêmes fonctions que le CLI. `$HOME`
est donc détourné vers un dossier jetable pour tout le module.
"""
import os
import tempfile
import unittest
from pathlib import Path
from script.todo.mail import accounts as mail_accounts
class FakeConfigFile:
"""Un `config_file` minimal — seuls `get_config_value`/`set_config_value`
sont utilisés par `account_setup`, pas besoin du vrai `ConfigFile`."""
def __init__(self):
self._values: dict = {}
def get_config_value(self, keys):
node = self._values
for key in keys:
if not isinstance(node, dict) or key not in node:
return None
node = node[key]
return node
def set_config_value(self, keys, value):
node = self._values
for key in keys[:-1]:
node = node.setdefault(key, {})
node[keys[-1]] = value
class FakeSecretStore:
"""Coffre en mémoire : suffisant pour vérifier ce que le formulaire y
écrit, sans toucher ni pykeepass ni le trousseau système."""
def __init__(self):
self._data: dict = {}
def available_backends(self):
return ["kdbx"]
def get(self, ref):
return self._data.get(ref)
def set(self, ref, value):
self._data[ref] = value
def delete(self, ref):
self._data.pop(ref, None)
class FakeTransport:
def list_folders(self):
return []
def logout(self):
pass
class TuiAccountCase(unittest.IsolatedAsyncioTestCase):
def setUp(self):
# Ces tests comparent des libellés d'arbre en français : ils fixent
# donc la langue au lieu d'hériter de celle que le fichier précédent
# a laissée, sinon ils passent seuls et échouent dans la suite
# complète — ce qui est arrivé.
#
# On écrit la mémoïsation directement : `set_lang()` PERSISTE la
# langue dans ./env_var.sh, un fichier suivi par git, donc l'appeler
# depuis un test modifierait l'arbre de travail.
from script.todo import todo_i18n
self._old_lang = todo_i18n._current_lang
todo_i18n._current_lang = "fr"
self.fake_home = tempfile.TemporaryDirectory()
self._old_home = os.environ.get("HOME")
os.environ["HOME"] = self.fake_home.name
self.cache_dir = tempfile.TemporaryDirectory()
self.config_file = FakeConfigFile()
# Un kdbx déjà configuré : le flux saute `VaultScreen` et va droit à
# `AccountScreen`, ce que couvrent les tests de cette classe.
self.config_file.set_config_value(
["kdbx", "path"], "/already/configured.kdbx"
)
self.secret_store = FakeSecretStore()
def tearDown(self):
from script.todo import todo_i18n
# Rendre la langue telle qu'on l'a trouvée : ne pas reproduire sur
# les tests suivants la fuite qui a cassé ceux-ci.
todo_i18n._current_lang = self._old_lang
if self._old_home is None:
os.environ.pop("HOME", None)
else:
os.environ["HOME"] = self._old_home
self.fake_home.cleanup()
self.cache_dir.cleanup()
async def _mounted_app(self, sessions=None):
# `run_tui(run_app=False, ...)` ne renvoie rien : capter la première
# `App` construite, comme `test_mail_compose.py`.
import textual.app
from script.todo.mail.tui import run_tui
captured = []
orig_init = textual.app.App.__init__
def capturing_init(app_self, *a, **kw):
orig_init(app_self, *a, **kw)
captured.append(app_self)
textual.app.App.__init__ = capturing_init
try:
run_tui(
run_app=False,
sessions=sessions or [],
config_file=self.config_file,
secret_store=self.secret_store,
connect_fn=lambda account, password: FakeTransport(),
base=Path(self.cache_dir.name),
)
finally:
textual.app.App.__init__ = orig_init
return captured[-1]
class TestAccountNodeAndBinding(TuiAccountCase):
async def test_add_account_leaf_is_at_the_bottom_of_the_tree(self):
from textual.widgets import Tree
app = await self._mounted_app()
async with app.run_test() as pilot:
await pilot.pause()
tree = app.query_one("#folders", Tree)
labels = [str(child.label) for child in tree.root.children]
self.assertTrue(
any("Ajouter un compte" in label for label in labels)
)
async def test_n_opens_the_account_form(self):
from textual.screen import ModalScreen
from textual.widgets import Input
app = await self._mounted_app()
async with app.run_test() as pilot:
await pilot.pause()
await pilot.press("n")
await pilot.pause()
self.assertIsInstance(app.screen, ModalScreen)
# Le kdbx est déjà configuré : c'est `AccountScreen`, pas
# `VaultScreen`, qui doit s'ouvrir — la présence de `#acc_name`
# le distingue sans exposer les classes imbriquées.
self.assertIsNotNone(app.screen.query_one("#acc_name", Input))
async def test_add_account_node_opens_the_same_screen_as_n(self):
from textual.screen import ModalScreen
from textual.widgets import Input, Tree
app = await self._mounted_app()
async with app.run_test() as pilot:
await pilot.pause()
tree = app.query_one("#folders", Tree)
add_node = next(
child
for child in tree.root.children
if "Ajouter un compte" in str(child.label)
)
tree.select_node(add_node)
tree.action_select_cursor()
await pilot.pause()
self.assertIsInstance(app.screen, ModalScreen)
self.assertIsNotNone(app.screen.query_one("#acc_name", Input))
class TestAccountScreenSavesAndGoesLive(TuiAccountCase):
"""La couture qui compte : un compte sauvegardé doit apparaître dans
`self.sessions` ET dans l'arbre, sans redémarrer le TUI."""
async def test_valid_submission_is_saved_and_usable_immediately(self):
from textual.screen import ModalScreen
from textual.widgets import Input, Select, Tree
app = await self._mounted_app()
async with app.run_test() as pilot:
await pilot.pause()
await pilot.press("n")
await pilot.pause()
screen = app.screen
screen.query_one("#acc_name", Input).value = "perso"
screen.query_one("#acc_email", Input).value = "moi@x.ca"
screen.query_one("#acc_password", Input).value = "hunter2"
self.assertEqual(
screen.query_one("#acc_preset", Select).value, "generic"
)
screen.query_one("#acc_imap", Input).value = "imap.x.ca"
screen.query_one("#acc_smtp", Input).value = "smtp.x.ca"
await pilot.press("ctrl+s")
await pilot.pause()
await app.workers.wait_for_complete()
await pilot.pause()
# L'écran s'est refermé.
self.assertNotIsInstance(app.screen, ModalScreen)
# Le fichier de comptes le confirme.
saved = mail_accounts.load()
self.assertEqual([a.name for a in saved], ["perso"])
self.assertEqual(
self.secret_store.get(saved[0].secret_ref), "hunter2"
)
# ET le TUI en tient une session utilisable tout de suite.
self.assertEqual(len(app.sessions), 1)
self.assertEqual(app.sessions[0].account.name, "perso")
# ET l'arbre la montre, sans redémarrer.
tree = app.query_one("#folders", Tree)
labels = [str(child.label) for child in tree.root.children]
self.assertTrue(any("perso" in label for label in labels))
async def test_cancel_creates_nothing(self):
from textual.screen import ModalScreen
from textual.widgets import Input
app = await self._mounted_app()
async with app.run_test() as pilot:
await pilot.pause()
await pilot.press("n")
await pilot.pause()
app.screen.query_one("#acc_name", Input).value = "perso"
await pilot.press("escape")
await pilot.pause()
self.assertNotIsInstance(app.screen, ModalScreen)
self.assertEqual(mail_accounts.load(), [])
self.assertEqual(app.sessions, [])
async def test_invalid_name_is_refused_and_the_screen_stays_open(self):
from textual.screen import ModalScreen
from textual.widgets import Input, Static
app = await self._mounted_app()
async with app.run_test() as pilot:
await pilot.pause()
await pilot.press("n")
await pilot.pause()
screen = app.screen
# `/` est refusé par `Account.__post_init__` : le message doit
# se lire sur l'écran, pas remonter en exception.
screen.query_one("#acc_name", Input).value = "per/so"
screen.query_one("#acc_email", Input).value = "moi@x.ca"
screen.query_one("#acc_password", Input).value = "hunter2"
await pilot.press("ctrl+s")
await pilot.pause()
self.assertIsInstance(app.screen, ModalScreen)
status = screen.query_one("#account_status", Static)
self.assertTrue(str(status.content))
self.assertEqual(mail_accounts.load(), [])
async def test_missing_password_is_refused_with_a_message(self):
from textual.screen import ModalScreen
from textual.widgets import Input, Static
app = await self._mounted_app()
async with app.run_test() as pilot:
await pilot.pause()
await pilot.press("n")
await pilot.pause()
screen = app.screen
screen.query_one("#acc_name", Input).value = "perso"
screen.query_one("#acc_email", Input).value = "moi@x.ca"
await pilot.press("ctrl+s")
await pilot.pause()
self.assertIsInstance(app.screen, ModalScreen)
status = screen.query_one("#account_status", Static)
self.assertTrue(str(status.content))
self.assertEqual(mail_accounts.load(), [])
class TestVaultScreenFirst(TuiAccountCase):
"""Sans kdbx configuré, `VaultScreen` s'ouvre avant `AccountScreen`, et
l'annuler annule tout le flux."""
def setUp(self):
super().setUp()
# Ce groupe teste justement l'ABSENCE de configuration.
self.config_file = FakeConfigFile()
async def test_no_kdbx_configured_opens_vault_screen_first(self):
from textual.screen import ModalScreen
from textual.widgets import Input
app = await self._mounted_app()
async with app.run_test() as pilot:
await pilot.pause()
await pilot.press("n")
await pilot.pause()
self.assertIsInstance(app.screen, ModalScreen)
self.assertIsNotNone(app.screen.query_one("#vault_path", Input))
async def test_cancelling_the_vault_cancels_the_whole_flow(self):
from textual.screen import ModalScreen
app = await self._mounted_app()
async with app.run_test() as pilot:
await pilot.pause()
await pilot.press("n")
await pilot.pause()
await pilot.press("escape")
await pilot.pause()
self.assertNotIsInstance(app.screen, ModalScreen)
self.assertIsNone(
self.config_file.get_config_value(["kdbx", "path"])
)
async def test_creating_the_vault_then_proceeds_to_the_account_form(self):
from textual.screen import ModalScreen
from textual.widgets import Input
app = await self._mounted_app()
async with app.run_test() as pilot:
await pilot.pause()
await pilot.press("n")
await pilot.pause()
vault_path = os.path.join(self.cache_dir.name, "new.kdbx")
app.screen.query_one("#vault_path", Input).value = vault_path
app.screen.query_one("#vault_password", Input).value = "hunter2"
app.screen.query_one(
"#vault_password_confirm", Input
).value = "hunter2"
await pilot.click("#vault_create")
await pilot.pause()
self.assertIsInstance(app.screen, ModalScreen)
self.assertIsNotNone(app.screen.query_one("#acc_name", Input))
self.assertTrue(os.path.isfile(vault_path))
self.assertEqual(
self.config_file.get_config_value(["kdbx", "path"]),
vault_path,
)
async def test_mismatched_vault_passwords_are_refused(self):
from textual.screen import ModalScreen
from textual.widgets import Input, Static
app = await self._mounted_app()
async with app.run_test() as pilot:
await pilot.pause()
await pilot.press("n")
await pilot.pause()
vault_path = os.path.join(self.cache_dir.name, "new.kdbx")
app.screen.query_one("#vault_path", Input).value = vault_path
app.screen.query_one("#vault_password", Input).value = "hunter2"
app.screen.query_one(
"#vault_password_confirm", Input
).value = "autrechose"
await pilot.click("#vault_create")
await pilot.pause()
self.assertIsInstance(app.screen, ModalScreen)
status = app.screen.query_one("#vault_status", Static)
self.assertTrue(str(status.content))
self.assertFalse(os.path.isfile(vault_path))
class TestVaultScreenSurvivesDiskErrors(TuiAccountCase):
"""`_create`/`_choose` do real disk I/O (`create_kdbx`,
`ConfigFile.set_config_value`) and only caught `SecretError` — a plain
`OSError` (disque plein, permission refusée) escaped into Textual's own
handler, which renders every local, including the plaintext vault
password sitting right there in the same frame."""
def setUp(self):
super().setUp()
# Ce groupe teste justement l'ABSENCE de configuration : c'est
# `VaultScreen`, pas `AccountScreen`, qui est en cause ici.
self.config_file = FakeConfigFile()
async def test_create_vault_oserror_keeps_the_screen_open(self):
from unittest.mock import patch
from textual.screen import ModalScreen
from textual.widgets import Input, Static
app = await self._mounted_app()
async with app.run_test() as pilot:
await pilot.pause()
await pilot.press("n")
await pilot.pause()
vault_path = os.path.join(self.cache_dir.name, "new.kdbx")
app.screen.query_one("#vault_path", Input).value = vault_path
app.screen.query_one("#vault_password", Input).value = "hunter2"
app.screen.query_one(
"#vault_password_confirm", Input
).value = "hunter2"
with patch(
"script.todo.mail.account_setup.create_vault",
side_effect=OSError("disque plein"),
):
await pilot.click("#vault_create")
await pilot.pause()
# Toujours un ModalScreen : ni plantage, ni fermeture d'écran.
self.assertIsInstance(app.screen, ModalScreen)
status = app.screen.query_one("#vault_status", Static)
self.assertTrue(str(status.content))
self.assertFalse(os.path.isfile(vault_path))
async def test_create_reports_an_unopenable_vault_itself(self):
"""`_create` crée le fichier PUIS l'ouvre tout de suite,
symétriquement à `_choose` : un coffre créé mais inouvrable
(mauvaise entropie, corruption immédiate, ...) doit se signaler ICI,
pas plus tard sur `AccountScreen` — l'utilisateur peut encore agir
sur le coffre à cet instant précis."""
from unittest.mock import patch
from pykeepass.exceptions import CredentialsError
from textual.screen import ModalScreen
from textual.widgets import Input, Static
app = await self._mounted_app()
async with app.run_test() as pilot:
await pilot.pause()
await pilot.press("n")
await pilot.pause()
vault_path = os.path.join(self.cache_dir.name, "new.kdbx")
app.screen.query_one("#vault_path", Input).value = vault_path
app.screen.query_one("#vault_password", Input).value = "hunter2"
app.screen.query_one(
"#vault_password_confirm", Input
).value = "hunter2"
# `create_vault` (donc `create_kdbx`) tourne pour de vrai — le
# fichier existe. Seule l'OUVERTURE qui suit échoue.
with patch(
"pykeepass.PyKeePass",
side_effect=CredentialsError("mauvais mot de passe"),
):
await pilot.click("#vault_create")
await pilot.pause()
# Toujours `VaultScreen` : `#vault_path` n'existe que là,
# `AccountScreen` ne l'a jamais poussé.
self.assertIsInstance(app.screen, ModalScreen)
self.assertIsNotNone(app.screen.query_one("#vault_path", Input))
status = app.screen.query_one("#vault_status", Static)
self.assertTrue(str(status.content))
self.assertTrue(os.path.isfile(vault_path))
async def test_use_existing_vault_oserror_keeps_the_screen_open(self):
from unittest.mock import patch
from textual.screen import ModalScreen
from textual.widgets import Input, Static
app = await self._mounted_app()
async with app.run_test() as pilot:
await pilot.pause()
await pilot.press("n")
await pilot.pause()
existing_path = os.path.join(self.cache_dir.name, "existing.kdbx")
with open(existing_path, "wb") as handle:
handle.write(b"not a real kdbx, just a file")
app.screen.query_one("#vault_path", Input).value = existing_path
app.screen.query_one("#vault_password", Input).value = "hunter2"
with patch(
"script.todo.mail.account_setup.use_existing_vault",
side_effect=OSError("permission refusée"),
):
await pilot.click("#vault_choose")
await pilot.pause()
self.assertIsInstance(app.screen, ModalScreen)
status = app.screen.query_one("#vault_status", Static)
self.assertTrue(str(status.content))
async def test_guard_covers_the_first_statement_in_create(self):
"""Protection structurelle (round 3) : trois manches ont chacune
trouvé un appel qui tournait hors garde parce que le `try` ouvrait
trop tard. Ce test casse la TOUTE PREMIÈRE lecture faite à
l'intérieur du `try` de `_create` (`#vault_path`) — s'il repasse au
rouge, c'est que le `try` a de nouveau été repoussé plus bas."""
from textual.screen import ModalScreen
from textual.widgets import Input, Static
app = await self._mounted_app()
async with app.run_test() as pilot:
await pilot.pause()
await pilot.press("n")
await pilot.pause()
screen = app.screen
orig_query_one = screen.query_one
def failing_query_one(selector, *args, **kwargs):
if selector == "#vault_path":
raise RuntimeError("échec simulé sur la 1re lecture")
return orig_query_one(selector, *args, **kwargs)
screen.query_one = failing_query_one
await pilot.click("#vault_create")
await pilot.pause()
self.assertIsInstance(app.screen, ModalScreen)
status = orig_query_one("#vault_status", Static)
self.assertTrue(str(status.content))
async def test_guard_covers_the_first_statement_in_choose(self):
"""Même protection que ci-dessus, pour `_choose`."""
from textual.screen import ModalScreen
from textual.widgets import Input, Static
app = await self._mounted_app()
async with app.run_test() as pilot:
await pilot.pause()
await pilot.press("n")
await pilot.pause()
screen = app.screen
orig_query_one = screen.query_one
def failing_query_one(selector, *args, **kwargs):
if selector == "#vault_path":
raise RuntimeError("échec simulé sur la 1re lecture")
return orig_query_one(selector, *args, **kwargs)
screen.query_one = failing_query_one
await pilot.click("#vault_choose")
await pilot.pause()
self.assertIsInstance(app.screen, ModalScreen)
status = orig_query_one("#vault_status", Static)
self.assertTrue(str(status.content))
class TestAccountScreenSurvivesDiskErrors(TuiAccountCase):
"""`action_save` reads `mail_accounts.load()` to compute `existing`
BEFORE its own try/except — an `OSError` there (accounts.json illisible)
escaped uncaught, with the plaintext account password still a local in
that same frame."""
async def test_load_oserror_keeps_the_screen_open(self):
from unittest.mock import patch
from textual.screen import ModalScreen
from textual.widgets import Input, Static
app = await self._mounted_app()
async with app.run_test() as pilot:
await pilot.pause()
await pilot.press("n")
await pilot.pause()
screen = app.screen
screen.query_one("#acc_name", Input).value = "perso"
screen.query_one("#acc_email", Input).value = "moi@x.ca"
screen.query_one("#acc_password", Input).value = "hunter2"
screen.query_one("#acc_imap", Input).value = "imap.x.ca"
screen.query_one("#acc_smtp", Input).value = "smtp.x.ca"
with patch(
"script.todo.mail.accounts.load",
side_effect=OSError("disque plein"),
):
await pilot.press("ctrl+s")
await pilot.pause()
self.assertIsInstance(app.screen, ModalScreen)
status = screen.query_one("#account_status", Static)
self.assertTrue(str(status.content))
self.assertEqual(app.sessions, [])
async def test_pykeepass_error_during_save_keeps_the_screen_open(self):
"""`secret_store.set()` peut atteindre `PyKeePass(...)` pour la
première fois ici (coffre tout juste créé sans ouverture immédiate,
avant le fix `_create`, ou coffre modifié entre-temps) : pykeepass
lève `CredentialsError`/`HeaderChecksumError`/..., qui ne sont PAS
des `OSError` — le guard doit rester `except Exception` pour ne
jamais laisser passer `password` en clair vers la traceback de
Textual."""
from pykeepass.exceptions import CredentialsError
from textual.screen import ModalScreen
from textual.widgets import Input, Static
def raise_credentials_error(ref, value):
raise CredentialsError("mauvais mot de passe")
self.secret_store.set = raise_credentials_error
app = await self._mounted_app()
async with app.run_test() as pilot:
await pilot.pause()
await pilot.press("n")
await pilot.pause()
screen = app.screen
screen.query_one("#acc_name", Input).value = "perso"
screen.query_one("#acc_email", Input).value = "moi@x.ca"
screen.query_one("#acc_password", Input).value = "hunter2"
screen.query_one("#acc_imap", Input).value = "imap.x.ca"
screen.query_one("#acc_smtp", Input).value = "smtp.x.ca"
await pilot.press("ctrl+s")
await pilot.pause()
self.assertIsInstance(app.screen, ModalScreen)
status = screen.query_one("#account_status", Static)
self.assertTrue(str(status.content))
self.assertEqual(app.sessions, [])
self.assertEqual(mail_accounts.load(), [])
async def test_available_backends_error_keeps_the_screen_open(self):
"""Le cinquième chemin (round 3) : `self.secret_store
.available_backends()` tournait hors de toute garde, alors que
`password` était déjà une variable locale. Le vrai
`keyring.core.load_config()` peut lever `ModuleNotFoundError` ou
`AttributeError` sur un backend configuré mais cassé — ni l'une ni
l'autre n'est une `OSError`."""
from textual.screen import ModalScreen
from textual.widgets import Input, Static
def raise_broken_backend():
raise ModuleNotFoundError("backend keyring introuvable")
self.secret_store.available_backends = raise_broken_backend
app = await self._mounted_app()
async with app.run_test() as pilot:
await pilot.pause()
await pilot.press("n")
await pilot.pause()
screen = app.screen
screen.query_one("#acc_name", Input).value = "perso"
screen.query_one("#acc_email", Input).value = "moi@x.ca"
screen.query_one("#acc_password", Input).value = "hunter2"
screen.query_one("#acc_imap", Input).value = "imap.x.ca"
screen.query_one("#acc_smtp", Input).value = "smtp.x.ca"
await pilot.press("ctrl+s")
await pilot.pause()
self.assertIsInstance(app.screen, ModalScreen)
status = screen.query_one("#account_status", Static)
self.assertTrue(str(status.content))
self.assertEqual(app.sessions, [])
async def test_guard_covers_the_first_statement_in_action_save(self):
"""Protection structurelle (round 3) : casse la TOUTE PREMIÈRE
lecture faite à l'intérieur du `try` d'`action_save` (`#acc_name`)
— si ce test repasse au rouge, c'est que le `try` a de nouveau été
repoussé après cette ligne."""
from textual.screen import ModalScreen
from textual.widgets import Static
app = await self._mounted_app()
async with app.run_test() as pilot:
await pilot.pause()
await pilot.press("n")
await pilot.pause()
screen = app.screen
orig_query_one = screen.query_one
def failing_query_one(selector, *args, **kwargs):
if selector == "#acc_name":
raise RuntimeError("échec simulé sur la 1re lecture")
return orig_query_one(selector, *args, **kwargs)
screen.query_one = failing_query_one
await pilot.press("ctrl+s")
await pilot.pause()
self.assertIsInstance(app.screen, ModalScreen)
status = orig_query_one("#account_status", Static)
self.assertTrue(str(status.content))
class TestPasswordClearedBeforeDismiss(TuiAccountCase):
"""Round 4 : `password`/`confirm` ne sont pas scopés au `try` par
Python — ils restent des noms liés dans le cadre de
`_create`/`_choose`/`action_save` jusqu'au retour de la fonction, garde
ou pas. Round 3 affirmait, à tort, que le `try` "s'étend jusqu'au
dismiss compris" ; il ne l'atteint pas textuellement.
Vérifié avant d'écrire quoi que ce soit (Textual 8.2.8,
`textual/screen.py:130` et `textual/message_pump.py:507-519,695-704`,
puis confirmé empiriquement par un script autonome) :
`Screen.dismiss()` ne rappelle PAS son callback de résultat
directement — `ResultCallback.__call__` fait
`self.requester.call_next(self.callback, result)`, qui EMPILE l'appel
pour le cycle de message SUIVANT. Ce callback (`_after_account_added`/
`_after_vault_screen`) tourne donc APRÈS que cette méthode soit
retournée pour de bon, dans un cadre d'appel disjoint — une exception
qui y survient n'inclut PAS le cadre de `action_save`/`_create`/
`_choose` dans sa traceback (vérifié : marcher `exc.__traceback__`
depuis un tel échec ne trouve jamais ce cadre). Le "sixième chemin" tel
que décrit (le cadre appelant vivant pendant un callback synchrone)
n'est donc pas démontré sur cette version de Textual.
Ce qui reste vrai et vaut la peine d'être gardé : `password`/`confirm`
n'ont plus aucun usage après le `try`, et les mettre à `None` avant
`dismiss()` ne coûte rien — de la défense en profondeur, pas la
fermeture d'une fuite démontrée. Ces tests vérifient donc la propriété
réelle du code : au moment où `dismiss()` est appelé, le mot de passe
n'est plus dans les locales de l'appelant — sans prétendre qu'un
callback en aval y aurait accès de toute façon."""
def setUp(self):
super().setUp()
# Par défaut : `_create`/`_choose` exigent l'ABSENCE de kdbx
# configuré. Le test `action_save` reconfigure un kdbx localement
# avant de monter l'app.
self.config_file = FakeConfigFile()
async def test_password_is_cleared_before_dismiss_in_action_save(self):
import sys
from textual.widgets import Input
self.config_file.set_config_value(
["kdbx", "path"], "/already/configured.kdbx"
)
app = await self._mounted_app()
captured = {}
async with app.run_test() as pilot:
await pilot.pause()
await pilot.press("n")
await pilot.pause()
screen = app.screen
screen.query_one("#acc_name", Input).value = "perso"
screen.query_one("#acc_email", Input).value = "moi@x.ca"
screen.query_one("#acc_password", Input).value = "hunter2"
screen.query_one("#acc_imap", Input).value = "imap.x.ca"
screen.query_one("#acc_smtp", Input).value = "smtp.x.ca"
orig_dismiss = type(screen).dismiss
def spying_dismiss(self_screen, result=None):
# Le cadre de l'APPELANT de `dismiss()` est `action_save`
# lui-même : c'est exactement ce qu'on veut inspecter.
caller = sys._getframe(1)
captured["password"] = caller.f_locals.get(
"password", "absent-des-locales"
)
return orig_dismiss(self_screen, result)
screen.dismiss = spying_dismiss.__get__(screen)
await pilot.press("ctrl+s")
await pilot.pause()
self.assertIn("password", captured)
self.assertIsNone(captured["password"])
async def test_password_is_cleared_before_dismiss_in_create(self):
import sys
from textual.widgets import Input
app = await self._mounted_app()
captured = {}
async with app.run_test() as pilot:
await pilot.pause()
await pilot.press("n")
await pilot.pause()
screen = app.screen
vault_path = os.path.join(self.cache_dir.name, "new.kdbx")
screen.query_one("#vault_path", Input).value = vault_path
screen.query_one("#vault_password", Input).value = "hunter2"
screen.query_one(
"#vault_password_confirm", Input
).value = "hunter2"
orig_dismiss = type(screen).dismiss
def spying_dismiss(self_screen, result=None):
caller = sys._getframe(1)
captured["password"] = caller.f_locals.get(
"password", "absent-des-locales"
)
captured["confirm"] = caller.f_locals.get(
"confirm", "absent-des-locales"
)
return orig_dismiss(self_screen, result)
screen.dismiss = spying_dismiss.__get__(screen)
await pilot.click("#vault_create")
await pilot.pause()
self.assertIn("password", captured)
self.assertIsNone(captured["password"])
self.assertIsNone(captured["confirm"])
async def test_password_is_cleared_before_dismiss_in_choose(self):
import sys
from pykeepass import create_database
from textual.widgets import Input
vault_path = os.path.join(self.cache_dir.name, "existing.kdbx")
create_database(vault_path, password="hunter2")
app = await self._mounted_app()
captured = {}
async with app.run_test() as pilot:
await pilot.pause()
await pilot.press("n")
await pilot.pause()
screen = app.screen
screen.query_one("#vault_path", Input).value = vault_path
screen.query_one("#vault_password", Input).value = "hunter2"
orig_dismiss = type(screen).dismiss
def spying_dismiss(self_screen, result=None):
caller = sys._getframe(1)
captured["password"] = caller.f_locals.get(
"password", "absent-des-locales"
)
return orig_dismiss(self_screen, result)
screen.dismiss = spying_dismiss.__get__(screen)
await pilot.click("#vault_choose")
await pilot.pause()
self.assertIn("password", captured)
self.assertIsNone(captured["password"])
if __name__ == "__main__":
unittest.main()