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
880 lines
34 KiB
Python
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()
|