An IMAP/SMTP client in the menu, with its tests against real servers. Most of the work went into refusals: an application password is named only when the server actually refuses, an accented password is reported as never having left the machine, and a refusal is recognised by what the server SAYS rather than by matching its wording. A malformed date no longer takes the whole folder down, and a received email is never read as markup. --- FR --- Un client IMAP/SMTP dans le menu, avec ses tests contre de vrais serveurs. L'essentiel du travail porte sur les refus : le mot de passe d'application n'est nommé que lorsque le serveur refuse vraiment, un mot de passe accentué est signalé comme n'ayant jamais quitté la machine, et un refus se reconnaît à ce que le serveur DIT plutôt qu'à ses mots. Une date illisible n'emporte plus le dossier entier, et un courriel reçu n'est jamais lu comme du balisage. Assisted-by: Claude Opus 5
431 lines
14 KiB
Python
431 lines
14 KiB
Python
#!/usr/bin/env python3
|
|
# © 2026 TechnoLibre (http://www.technolibre.ca)
|
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
|
|
|
import tempfile
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
from script.todo.mail.accounts import account_from_preset
|
|
from script.todo.mail.tui import (
|
|
MailboxRef,
|
|
Session,
|
|
mailbox_refs,
|
|
open_sessions,
|
|
)
|
|
|
|
|
|
class FailingConnect:
|
|
def __init__(self, message="serveur injoignable"):
|
|
self.message = message
|
|
|
|
def __call__(self, account, password):
|
|
raise OSError(self.message)
|
|
|
|
|
|
class FakeTransport:
|
|
def list_folders(self):
|
|
return []
|
|
|
|
def logout(self):
|
|
pass
|
|
|
|
|
|
class FakeSecrets:
|
|
def __init__(self, password="hunter2"):
|
|
self.password = password
|
|
|
|
def get(self, ref):
|
|
return self.password
|
|
|
|
def set(self, ref, value):
|
|
self.password = value
|
|
|
|
|
|
class SessionCase(unittest.TestCase):
|
|
def setUp(self):
|
|
self.tmp = tempfile.TemporaryDirectory()
|
|
self.base = Path(self.tmp.name)
|
|
self.accounts = [
|
|
account_from_preset("perso", "moi@x.ca", "generic"),
|
|
account_from_preset("travail", "moi@y.ca", "generic"),
|
|
]
|
|
# Épinglé : sans ça, resolve_mode lirait les préférences réelles de la
|
|
# machine et le test dépendrait de ~/.erplibre.
|
|
for account in self.accounts:
|
|
account.cache_mode = "clear"
|
|
|
|
def tearDown(self):
|
|
self.tmp.cleanup()
|
|
|
|
|
|
class TestOpenSessions(SessionCase):
|
|
def test_one_session_per_account(self):
|
|
sessions = open_sessions(
|
|
self.accounts,
|
|
FakeSecrets(),
|
|
base=self.base,
|
|
connect_fn=lambda a, p: FakeTransport(),
|
|
)
|
|
self.assertEqual(
|
|
[s.account.name for s in sessions], ["perso", "travail"]
|
|
)
|
|
for s in sessions:
|
|
s.close()
|
|
|
|
def test_disabled_account_is_skipped(self):
|
|
self.accounts[1].enabled = False
|
|
sessions = open_sessions(
|
|
self.accounts,
|
|
FakeSecrets(),
|
|
base=self.base,
|
|
connect_fn=lambda a, p: FakeTransport(),
|
|
)
|
|
self.assertEqual([s.account.name for s in sessions], ["perso"])
|
|
for s in sessions:
|
|
s.close()
|
|
|
|
def test_cache_opens_even_when_the_network_fails(self):
|
|
"""Réseau coupé : la boîte doit rester consultable."""
|
|
sessions = open_sessions(
|
|
self.accounts,
|
|
FakeSecrets(),
|
|
base=self.base,
|
|
connect_fn=FailingConnect(),
|
|
)
|
|
self.assertTrue(all(not s.online for s in sessions))
|
|
self.assertTrue(all(s.store is not None for s in sessions))
|
|
for s in sessions:
|
|
s.close()
|
|
|
|
def test_network_error_is_kept_for_display(self):
|
|
sessions = open_sessions(
|
|
self.accounts,
|
|
FakeSecrets(),
|
|
base=self.base,
|
|
connect_fn=FailingConnect("530 refus"),
|
|
)
|
|
self.assertIn("530", sessions[0].error)
|
|
for s in sessions:
|
|
s.close()
|
|
|
|
def test_missing_password_marks_offline(self):
|
|
class NoSecret:
|
|
def get(self, ref):
|
|
return None
|
|
|
|
sessions = open_sessions(
|
|
self.accounts,
|
|
NoSecret(),
|
|
base=self.base,
|
|
connect_fn=lambda a, p: FakeTransport(),
|
|
)
|
|
self.assertFalse(sessions[0].online)
|
|
for s in sessions:
|
|
s.close()
|
|
|
|
def test_online_when_everything_works(self):
|
|
sessions = open_sessions(
|
|
self.accounts,
|
|
FakeSecrets(),
|
|
base=self.base,
|
|
connect_fn=lambda a, p: FakeTransport(),
|
|
)
|
|
self.assertTrue(all(s.online for s in sessions))
|
|
for s in sessions:
|
|
s.close()
|
|
|
|
|
|
class TestEphemeralCleanupOnSignals(SessionCase):
|
|
"""`atexit` ne s'exécute pas sur un signal : sans un gestionnaire
|
|
`SIGINT`/`SIGTERM` dédié, un cache éphémère survivrait à un `kill` ou un
|
|
Ctrl+C — exactement ce que ce mode promet d'éviter.
|
|
|
|
On déclenche le gestionnaire installé directement plutôt que d'envoyer un
|
|
vrai signal au processus de test, et on restaure l'ancien gestionnaire
|
|
dans `tearDown` pour ne pas polluer le reste de la suite.
|
|
|
|
Piège vécu : quand la disposition précédente n'est PAS appelable
|
|
(`SIG_DFL`, le cas par défaut), le gestionnaire se renvoie maintenant
|
|
POUR DE VRAI le signal après le nettoyage — sinon il l'avalerait (voir
|
|
`test_sigterm_actually_terminates_the_process`). Appeler ce gestionnaire
|
|
directement avec la disposition par défaut en place tuerait donc le
|
|
processus de test lui-même : `test_sigterm_removes_the_ephemeral_root`
|
|
installe d'abord un `_previous` factice et appelable pour rester une
|
|
invocation directe sûre, et laisse la vraie fin de processus au test
|
|
suivant, seul endroit sûr pour l'observer (un sous-processus dédié).
|
|
"""
|
|
|
|
def setUp(self):
|
|
super().setUp()
|
|
import signal
|
|
|
|
self._orig_sigint = signal.getsignal(signal.SIGINT)
|
|
self._orig_sigterm = signal.getsignal(signal.SIGTERM)
|
|
|
|
def tearDown(self):
|
|
import signal
|
|
|
|
signal.signal(signal.SIGINT, self._orig_sigint)
|
|
signal.signal(signal.SIGTERM, self._orig_sigterm)
|
|
super().tearDown()
|
|
|
|
def test_sigterm_removes_the_ephemeral_root(self):
|
|
import signal
|
|
|
|
# Un `_previous` factice mais appelable : la branche de repli qui
|
|
# renvoie le signal pour de vrai (cas `SIG_DFL`) n'est PAS sûre à
|
|
# emprunter ici, puisqu'on invoque le gestionnaire directement dans
|
|
# le processus de test — elle est couverte séparément, en
|
|
# sous-processus, par `test_sigterm_actually_terminates_the_process`.
|
|
signal.signal(signal.SIGTERM, lambda signum, frame: None)
|
|
|
|
for account in self.accounts:
|
|
account.cache_mode = "ephemeral"
|
|
sessions = open_sessions(
|
|
self.accounts,
|
|
FakeSecrets(),
|
|
base=self.base,
|
|
connect_fn=lambda a, p: FakeTransport(),
|
|
)
|
|
roots = [s.store.root for s in sessions]
|
|
self.assertTrue(all(r.exists() for r in roots))
|
|
|
|
handler = signal.getsignal(signal.SIGTERM)
|
|
handler(signal.SIGTERM, None)
|
|
|
|
self.assertFalse(any(r.exists() for r in roots))
|
|
|
|
def test_sigterm_actually_terminates_the_process(self):
|
|
"""Un test qui ne vérifie QUE le nettoyage ne peut pas distinguer
|
|
« nettoyé puis sorti » de « nettoyé puis resté vivant » — c'est
|
|
exactement cette distinction que la régression a ratée : le
|
|
gestionnaire chaîné n'appelait le précédent handler que s'il était
|
|
`callable`, or la disposition par défaut de SIGTERM (`SIG_DFL`) est
|
|
l'entier 0, pas un appelable — le signal était donc avalé.
|
|
|
|
On lance un vrai sous-processus, on lui envoie SIGTERM pour de
|
|
vrai (pas un appel direct du handler), et on vérifie qu'il MEURT.
|
|
"""
|
|
import signal
|
|
import subprocess
|
|
import sys
|
|
|
|
repo_root = Path(__file__).resolve().parent.parent
|
|
with tempfile.TemporaryDirectory() as base_dir:
|
|
script = f"""
|
|
import os
|
|
import signal
|
|
import sys
|
|
import time
|
|
|
|
sys.path.insert(0, {str(repo_root)!r})
|
|
|
|
from script.todo.mail.accounts import account_from_preset
|
|
from script.todo.mail.tui import open_sessions
|
|
|
|
|
|
class FakeTransport:
|
|
def list_folders(self):
|
|
return []
|
|
|
|
def logout(self):
|
|
pass
|
|
|
|
|
|
class FakeSecrets:
|
|
def get(self, ref):
|
|
return "hunter2"
|
|
|
|
def set(self, ref, value):
|
|
pass
|
|
|
|
|
|
account = account_from_preset("perso", "moi@x.ca", "generic")
|
|
account.cache_mode = "ephemeral"
|
|
sessions = open_sessions(
|
|
[account],
|
|
FakeSecrets(),
|
|
base={str(base_dir)!r},
|
|
connect_fn=lambda a, p: FakeTransport(),
|
|
)
|
|
print(str(sessions[0].store.root), flush=True)
|
|
|
|
os.kill(os.getpid(), signal.SIGTERM)
|
|
|
|
# Ne doit JAMAIS s'imprimer : y arriver veut dire que le signal a été avalé.
|
|
time.sleep(2)
|
|
print("FAILURE: still alive after SIGTERM", flush=True)
|
|
"""
|
|
script_path = Path(base_dir) / "sigterm_child.py"
|
|
script_path.write_text(script)
|
|
|
|
result = subprocess.run(
|
|
[sys.executable, str(script_path)],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=10,
|
|
)
|
|
|
|
lines = [
|
|
line for line in result.stdout.splitlines() if line.strip()
|
|
]
|
|
self.assertTrue(
|
|
lines, f"aucune sortie de l'enfant : {result.stderr}"
|
|
)
|
|
root = Path(lines[0])
|
|
|
|
self.assertNotIn("still alive", result.stdout)
|
|
self.assertEqual(result.returncode, -signal.SIGTERM)
|
|
self.assertFalse(root.exists())
|
|
|
|
|
|
class TestMailboxRefs(SessionCase):
|
|
def setUp(self):
|
|
super().setUp()
|
|
self.sessions = open_sessions(
|
|
self.accounts,
|
|
FakeSecrets(),
|
|
base=self.base,
|
|
connect_fn=lambda a, p: FakeTransport(),
|
|
)
|
|
|
|
def tearDown(self):
|
|
for s in self.sessions:
|
|
s.close()
|
|
super().tearDown()
|
|
|
|
def test_empty_when_no_folder(self):
|
|
self.assertEqual(mailbox_refs(self.sessions), [])
|
|
|
|
def test_lists_folders_of_every_account(self):
|
|
self.sessions[0].store.upsert_folder("INBOX", "INBOX", "inbox")
|
|
self.sessions[1].store.upsert_folder("INBOX", "INBOX", "inbox")
|
|
refs = mailbox_refs(self.sessions)
|
|
self.assertEqual(
|
|
[(r.account_name, r.folder_name) for r in refs],
|
|
[("perso", "INBOX"), ("travail", "INBOX")],
|
|
)
|
|
|
|
def test_inbox_comes_first(self):
|
|
store = self.sessions[0].store
|
|
store.upsert_folder("Archives", "Archives", None)
|
|
store.upsert_folder("INBOX", "INBOX", "inbox")
|
|
names = [r.folder_name for r in mailbox_refs(self.sessions)]
|
|
self.assertEqual(names[0], "INBOX")
|
|
|
|
def test_carries_unseen_count(self):
|
|
store = self.sessions[0].store
|
|
store.upsert_folder("INBOX", "INBOX", "inbox")
|
|
store.set_folder_state("INBOX", unseen=4)
|
|
self.assertEqual(mailbox_refs(self.sessions)[0].unseen, 4)
|
|
|
|
def test_display_falls_back_to_name(self):
|
|
self.sessions[0].store.upsert_folder("Projets")
|
|
self.assertEqual(mailbox_refs(self.sessions)[0].display, "Projets")
|
|
|
|
|
|
class TestBrokenCache(SessionCase):
|
|
"""Un cache illisible sur UN compte ne doit pas couler les autres."""
|
|
|
|
def _corrupt(self, name):
|
|
root = self.base / name
|
|
root.mkdir(parents=True, exist_ok=True)
|
|
(root / "cache.db").write_bytes(b"pas une base sqlite" * 50)
|
|
|
|
def _open(self):
|
|
return open_sessions(
|
|
self.accounts,
|
|
FakeSecrets(),
|
|
base=self.base,
|
|
connect_fn=lambda a, p: FakeTransport(),
|
|
)
|
|
|
|
def test_the_other_accounts_still_open(self):
|
|
self._corrupt("perso")
|
|
sessions = self._open()
|
|
self.assertEqual(
|
|
[s.account.name for s in sessions], ["perso", "travail"]
|
|
)
|
|
self.assertIsNone(sessions[0].store)
|
|
self.assertIsNotNone(sessions[1].store)
|
|
for session in sessions:
|
|
session.close()
|
|
|
|
def test_the_broken_account_keeps_its_error(self):
|
|
self._corrupt("perso")
|
|
sessions = self._open()
|
|
self.assertIn("cache.db", sessions[0].error)
|
|
for session in sessions:
|
|
session.close()
|
|
|
|
def test_the_broken_account_is_offline(self):
|
|
self._corrupt("perso")
|
|
sessions = self._open()
|
|
self.assertFalse(sessions[0].online)
|
|
for session in sessions:
|
|
session.close()
|
|
|
|
def test_mailbox_refs_skips_it_without_raising(self):
|
|
self._corrupt("perso")
|
|
sessions = self._open()
|
|
sessions[1].store.upsert_folder("INBOX", "INBOX", "inbox")
|
|
refs = mailbox_refs(sessions)
|
|
self.assertEqual([r.account_name for r in refs], ["travail"])
|
|
for session in sessions:
|
|
session.close()
|
|
|
|
def test_closing_a_broken_session_does_not_raise(self):
|
|
self._corrupt("perso")
|
|
sessions = self._open()
|
|
for session in sessions:
|
|
session.close()
|
|
|
|
|
|
class TestImportsWithoutTextual(unittest.TestCase):
|
|
def test_module_imports_without_textual(self):
|
|
"""Le module doit rester utilisable là où Textual n'est pas installé."""
|
|
import script.todo.mail.tui as tui
|
|
|
|
self.assertTrue(hasattr(tui, "run_tui"))
|
|
|
|
|
|
class TestSaveAttachment(unittest.TestCase):
|
|
RAW = (
|
|
b'Content-Type: multipart/mixed; boundary="B"\r\n\r\n'
|
|
b"--B\r\nContent-Type: text/plain\r\n\r\ncorps\r\n"
|
|
b"--B\r\nContent-Type: application/pdf\r\n"
|
|
b'Content-Disposition: attachment; filename="devis.pdf"\r\n\r\n'
|
|
b"%PDF\r\n--B--\r\n"
|
|
)
|
|
|
|
def setUp(self):
|
|
self.tmp = tempfile.TemporaryDirectory()
|
|
|
|
def tearDown(self):
|
|
self.tmp.cleanup()
|
|
|
|
def test_writes_the_file(self):
|
|
from script.todo.mail.tui import save_attachment
|
|
|
|
target = save_attachment(self.RAW, 0, self.tmp.name)
|
|
self.assertTrue(target.exists())
|
|
self.assertEqual(target.name, "devis.pdf")
|
|
|
|
def test_unknown_index_raises(self):
|
|
from script.todo.mail.tui import save_attachment
|
|
|
|
with self.assertRaises(ValueError):
|
|
save_attachment(self.RAW, 7, self.tmp.name)
|
|
|
|
def test_filename_cannot_escape_the_directory(self):
|
|
"""Le nom vient du message : il ne doit jamais écrire ailleurs."""
|
|
from script.todo.mail.tui import save_attachment
|
|
|
|
hostile = self.RAW.replace(b'"devis.pdf"', b'"../../evade.pdf"')
|
|
target = save_attachment(hostile, 0, self.tmp.name)
|
|
self.assertEqual(target.parent, Path(self.tmp.name))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|