#!/usr/bin/env python3 # © 2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) import os import tempfile import unittest from pathlib import Path from script.todo.mail.accounts import account_from_preset from script.todo.mail.crypto import CryptoError, new_key from script.todo.mail.store import ( EPHEMERAL_PREFIX, MessageMeta, Store, StoreError, cache_root, folder_dirname, resolve_mode, sweep_orphan_ephemeral, ) def meta(uid, subject="Sujet", frm="a@x.ca", date=1000, flags=""): return MessageMeta( uid=uid, date=date, size=42, flags=flags, msgid=f"<{uid}@x.ca>", frm=frm, to="moi@x.ca", subject=subject, snippet="debut du corps", ) class TestResolveMode(unittest.TestCase): def test_account_override_wins(self): acc = account_from_preset("perso", "a@x.ca", "generic") acc.cache_mode = "encrypted" self.assertEqual( resolve_mode(acc, lambda k, d=None: "clear"), "encrypted" ) def test_falls_back_to_general_default(self): acc = account_from_preset("perso", "a@x.ca", "generic") self.assertEqual( resolve_mode(acc, lambda k, d=None: "ephemeral"), "ephemeral" ) def test_unknown_general_default_falls_back_to_clear(self): acc = account_from_preset("perso", "a@x.ca", "generic") self.assertEqual( resolve_mode(acc, lambda k, d=None: "magique"), "clear" ) class TestFolderDirname(unittest.TestCase): def test_slash_is_escaped(self): self.assertNotIn("/", folder_dirname("[Gmail]/Sent Mail")) def test_is_reversible_enough_to_be_unique(self): self.assertNotEqual(folder_dirname("A/B"), folder_dirname("A_B")) def test_traversal_collapses_to_one_component(self): self.assertNotIn("/", folder_dirname("../../etc")) def test_degenerate_names_cannot_designate_the_parent(self): """`racine / ".."` remonterait d'un cran : ces noms sont réécrits.""" for hostile in ("", ".", ".."): self.assertNotIn(folder_dirname(hostile), ("", ".", "..")) def test_dotted_hierarchy_stays_readable(self): """Le point sépare la hiérarchie chez beaucoup de serveurs IMAP.""" self.assertEqual(folder_dirname("INBOX.Sent"), "INBOX.Sent") class TestCacheRoot(unittest.TestCase): def test_persistent_modes_use_base(self): acc = account_from_preset("perso", "a@x.ca", "generic") with tempfile.TemporaryDirectory() as tmp: root = cache_root(acc, "clear", Path(tmp)) self.assertEqual(root, Path(tmp) / "perso") def test_ephemeral_root_carries_the_pid(self): acc = account_from_preset("perso", "a@x.ca", "generic") with tempfile.TemporaryDirectory() as tmp: root = cache_root(acc, "ephemeral", Path(tmp)) self.assertIn(f"{EPHEMERAL_PREFIX}{os.getpid()}", str(root)) class StoreCase(unittest.TestCase): """Socle commun : un compte, une base temporaire, mode paramétrable.""" mode = "clear" def setUp(self): self.tmp = tempfile.TemporaryDirectory() self.account = account_from_preset("perso", "moi@x.ca", "generic") self.key = new_key() if self.mode != "clear" else None self.store = Store( self.account, mode=self.mode, key=self.key, base=Path(self.tmp.name), ) self.store.open() def tearDown(self): self.store.close() self.tmp.cleanup() class TestSchema(StoreCase): def test_db_file_created(self): self.assertTrue((self.store.root / "cache.db").exists()) def test_root_is_0700(self): import stat self.assertEqual(stat.S_IMODE(os.stat(self.store.root).st_mode), 0o700) def test_reopen_is_idempotent(self): self.store.close() again = Store( self.account, mode=self.mode, key=self.key, base=Path(self.tmp.name), ) again.open() again.close() class TestFolders(StoreCase): def test_upsert_returns_id(self): fid = self.store.upsert_folder("INBOX", "INBOX", "inbox", 1, 10) self.assertIsInstance(fid, int) def test_upsert_twice_keeps_same_id(self): first = self.store.upsert_folder("INBOX") second = self.store.upsert_folder("INBOX") self.assertEqual(first, second) def test_folder_state(self): self.store.upsert_folder("INBOX", uidvalidity=7) state = self.store.folder_state("INBOX") self.assertEqual(state["uidvalidity"], 7) self.assertEqual(state["last_uid"], 0) def test_set_folder_state(self): self.store.upsert_folder("INBOX") self.store.set_folder_state("INBOX", last_uid=99, unseen=3) state = self.store.folder_state("INBOX") self.assertEqual(state["last_uid"], 99) self.assertEqual(state["unseen"], 3) def test_unknown_folder_state_is_none(self): self.assertIsNone(self.store.folder_state("ABSENT")) def test_folders_lists_them(self): self.store.upsert_folder("INBOX") self.store.upsert_folder("Sent") self.assertEqual( {f["name"] for f in self.store.folders()}, {"INBOX", "Sent"} ) def test_display_is_null_until_one_is_known(self): """NULL veut dire « inconnu » : le lecteur retombe sur le nom IMAP.""" self.store.upsert_folder("INBOX") self.assertIsNone(self.store.folder_state("INBOX")["display"]) def test_partial_upsert_keeps_the_display_name(self): """Une resync qui ne repasse que le nom IMAP ne doit rien écraser.""" self.store.upsert_folder("INBOX", "Boîte de réception") self.store.upsert_folder("INBOX") self.assertEqual( self.store.folder_state("INBOX")["display"], "Boîte de réception" ) class TestMessages(StoreCase): def setUp(self): super().setUp() self.fid = self.store.upsert_folder("INBOX") def test_upsert_then_list(self): self.store.upsert_messages(self.fid, [meta(1), meta(2)]) got = self.store.list_messages(self.fid) self.assertEqual({m.uid for m in got}, {1, 2}) def test_subject_survives_roundtrip(self): self.store.upsert_messages(self.fid, [meta(1, subject="Devis révisé")]) self.assertEqual( self.store.list_messages(self.fid)[0].subject, "Devis révisé" ) def test_upsert_same_uid_updates(self): self.store.upsert_messages(self.fid, [meta(1, subject="ancien")]) self.store.upsert_messages(self.fid, [meta(1, subject="nouveau")]) got = self.store.list_messages(self.fid) self.assertEqual(len(got), 1) self.assertEqual(got[0].subject, "nouveau") def test_sorted_by_date_desc(self): self.store.upsert_messages( self.fid, [meta(1, date=100), meta(2, date=300), meta(3, date=200)] ) self.assertEqual( [m.uid for m in self.store.list_messages(self.fid)], [2, 3, 1] ) def test_update_flags(self): self.store.upsert_messages(self.fid, [meta(1)]) self.store.update_flags(self.fid, 1, "\\Seen") self.assertEqual(self.store.list_messages(self.fid)[0].flags, "\\Seen") def test_known_uids(self): self.store.upsert_messages(self.fid, [meta(1), meta(2), meta(3)]) self.assertEqual(sorted(self.store.known_uids(self.fid)), [1, 2, 3]) def test_limit_and_offset(self): self.store.upsert_messages( self.fid, [meta(i, date=i) for i in range(1, 6)] ) self.assertEqual( [m.uid for m in self.store.list_messages(self.fid, limit=2)], [5, 4], ) self.assertEqual( [ m.uid for m in self.store.list_messages(self.fid, limit=2, offset=2) ], [3, 2], ) class TestBodies(StoreCase): def test_write_then_read(self): self.store.upsert_folder("INBOX") self.store.write_body("INBOX", 1, b"From: a@x.ca\r\n\r\nBonjour") self.assertEqual( self.store.read_body("INBOX", 1), b"From: a@x.ca\r\n\r\nBonjour" ) def test_missing_body_is_none(self): self.assertIsNone(self.store.read_body("INBOX", 404)) def test_has_body_flag_is_set(self): fid = self.store.upsert_folder("INBOX") self.store.upsert_messages(fid, [meta(1)]) self.store.write_body("INBOX", 1, b"corps") self.assertTrue(self.store.list_messages(fid)[0].has_body) def test_folder_with_slash(self): self.store.upsert_folder("[Gmail]/Sent Mail") self.store.write_body("[Gmail]/Sent Mail", 1, b"corps") self.assertEqual( self.store.read_body("[Gmail]/Sent Mail", 1), b"corps" ) def test_no_window_at_the_process_umask(self): """`write_bytes` puis `chmod` laisserait le corps du message lisible à l'umask du process le temps entre les deux appels. Au moment où `chmod` est appelé, le fichier doit déjà être en 0600.""" import stat from unittest.mock import patch self.store.upsert_folder("INBOX") path = self.store._body_path("INBOX", 1) seen = [] original_chmod = os.chmod def spy(target, mode): if Path(target) == path: seen.append(stat.S_IMODE(os.stat(target).st_mode)) return original_chmod(target, mode) with patch("os.chmod", side_effect=spy): self.store.write_body("INBOX", 1, b"corps") self.assertEqual(seen, [0o600]) class TestPurge(StoreCase): def test_purge_folder_drops_rows_and_files(self): fid = self.store.upsert_folder("INBOX") self.store.upsert_messages(fid, [meta(1)]) self.store.write_body("INBOX", 1, b"corps") self.store.purge_folder("INBOX") self.assertEqual(self.store.list_messages(fid), []) self.assertIsNone(self.store.read_body("INBOX", 1)) def test_purge_folder_resets_last_uid(self): self.store.upsert_folder("INBOX") self.store.set_folder_state("INBOX", last_uid=50) self.store.purge_folder("INBOX") self.assertEqual(self.store.folder_state("INBOX")["last_uid"], 0) def test_purge_all(self): fid = self.store.upsert_folder("INBOX") self.store.upsert_messages(fid, [meta(1)]) self.store.purge_all() self.assertEqual(self.store.folders(), []) def test_size_bytes_grows(self): before = self.store.size_bytes() self.store.upsert_folder("INBOX") self.store.write_body("INBOX", 1, b"x" * 5000) self.assertGreater(self.store.size_bytes(), before) class TestEncryptedStore(TestMessages): """Le même contrat, en chiffré : rien ne doit changer du point de vue de l'appelant.""" mode = "encrypted" def test_subject_absent_from_db_file(self): self.store.upsert_messages(self.fid, [meta(1, subject="CONFIDENTIEL")]) self.store.close() raw = (self.store.root / "cache.db").read_bytes() self.assertNotIn(b"CONFIDENTIEL", raw) self.store.open() def test_body_file_is_encrypted(self): self.store.write_body("INBOX", 1, b"TEXTE SECRET") path = next((self.store.root).rglob("*.eml*")) self.assertNotIn(b"TEXTE SECRET", path.read_bytes()) def test_date_stays_queryable_in_clear(self): """Le tri doit rester du SQL : la date n'est pas scellée.""" self.store.upsert_messages(self.fid, [meta(1, date=12345)]) rows = self.store._conn.execute( "SELECT date FROM messages WHERE uid = 1" ).fetchall() self.assertEqual(rows[0][0], 12345) class TestWrongKey(unittest.TestCase): def test_reopening_with_another_key_raises(self): with tempfile.TemporaryDirectory() as tmp: acc = account_from_preset("perso", "a@x.ca", "generic") first = Store(acc, mode="encrypted", key=new_key(), base=Path(tmp)) first.open() fid = first.upsert_folder("INBOX") first.upsert_messages(fid, [meta(1, subject="secret")]) first.close() second = Store( acc, mode="encrypted", key=new_key(), base=Path(tmp) ) second.open() with self.assertRaises(CryptoError): second.list_messages(fid) second.close() class TestEphemeral(unittest.TestCase): def setUp(self): self.tmp = tempfile.TemporaryDirectory() self.account = account_from_preset("perso", "a@x.ca", "generic") def tearDown(self): self.tmp.cleanup() def test_cleanup_removes_everything(self): store = Store( self.account, mode="ephemeral", key=new_key(), base=Path(self.tmp.name), ) store.open() root = store.root store.write_body("INBOX", 1, b"corps") self.assertTrue(root.exists()) store.close() store.cleanup() self.assertFalse(root.exists()) def test_sweep_removes_dead_pid_dirs(self): base = Path(self.tmp.name) dead = base / f"{EPHEMERAL_PREFIX}999999999" dead.mkdir() alive = base / f"{EPHEMERAL_PREFIX}{os.getpid()}" alive.mkdir() removed = sweep_orphan_ephemeral(base) self.assertEqual(removed, 1) self.assertFalse(dead.exists()) self.assertTrue(alive.exists()) def test_sweep_ignores_foreign_dirs(self): base = Path(self.tmp.name) (base / "autre-chose").mkdir() self.assertEqual(sweep_orphan_ephemeral(base), 0) self.assertTrue((base / "autre-chose").exists()) class TestCorruptDatabase(unittest.TestCase): """Un open() raté ne doit pas laisser l'objet porteur d'un handle cassé.""" def setUp(self): self.tmp = tempfile.TemporaryDirectory() self.account = account_from_preset("perso", "a@x.ca", "generic") root = Path(self.tmp.name) / "perso" root.mkdir(parents=True) (root / "cache.db").write_bytes(b"ceci n'est pas une base sqlite" * 40) def tearDown(self): self.tmp.cleanup() def test_open_raises_store_error(self): store = Store(self.account, mode="clear", base=Path(self.tmp.name)) with self.assertRaises(StoreError): store.open() def test_failed_open_does_not_publish_the_connection(self): """Sinon le open() suivant réussirait en silence sur une base sans schéma.""" store = Store(self.account, mode="clear", base=Path(self.tmp.name)) with self.assertRaises(StoreError): store.open() with self.assertRaises(StoreError): store.open() class TestEphemeralIsolation(unittest.TestCase): def setUp(self): self.tmp = tempfile.TemporaryDirectory() self.base = Path(self.tmp.name) self.a = account_from_preset("perso", "a@x.ca", "generic") self.b = account_from_preset("travail", "b@x.ca", "generic") def tearDown(self): self.tmp.cleanup() def _store(self, account): store = Store(account, mode="ephemeral", key=new_key(), base=self.base) store.open() return store def test_cleanup_spares_the_sibling_account(self): """Le dossier par PID est partagé : l'effacer tuerait le voisin.""" first, second = self._store(self.a), self._store(self.b) first.write_body("INBOX", 1, b"corps a") second.write_body("INBOX", 1, b"corps b") first.cleanup() self.assertFalse(first.root.exists()) self.assertTrue(second.root.exists()) self.assertEqual(second.read_body("INBOX", 1), b"corps b") second.cleanup() def test_last_cleanup_removes_the_pid_directory(self): first, second = self._store(self.a), self._store(self.b) pid_dir = first.root.parent first.cleanup() self.assertTrue(pid_dir.exists()) second.cleanup() self.assertFalse(pid_dir.exists()) def test_pid_directory_is_0700(self): """/dev/shm est en 1777 : le dossier par PID ne doit rien laisser voir.""" import stat as stat_module store = self._store(self.a) mode = stat_module.S_IMODE(os.stat(store.root.parent).st_mode) self.assertEqual(mode, 0o700) store.cleanup() def test_symlinked_pid_directory_is_refused(self): """Un tiers peut pré-créer le chemin : on refuse de le suivre.""" target = self.base / "ailleurs" target.mkdir() (self.base / f"{EPHEMERAL_PREFIX}{os.getpid()}").symlink_to(target) store = Store(self.a, mode="ephemeral", key=new_key(), base=self.base) with self.assertRaises(StoreError): store.open() class TestKeyPersistence(unittest.TestCase): """Le seul chemin du module qui écrit de la matière de clé sur disque.""" class FakeVault: def __init__(self): self.data = {} def get(self, ref): return self.data.get(ref) def set(self, ref, value): self.data[ref] = value def setUp(self): self.tmp = tempfile.TemporaryDirectory() self.base = Path(self.tmp.name) self.account = account_from_preset("perso", "a@x.ca", "generic") self.vault = self.FakeVault() def tearDown(self): self.tmp.cleanup() def _store(self): store = Store( self.account, mode="encrypted", secrets=self.vault, base=self.base ) store.open() return store def test_first_open_stores_a_key_under_the_cache_key_ref(self): self._store().close() self.assertIn(self.account.cache_key_ref(), self.vault.data) def test_stored_key_is_base64_of_32_bytes(self): import base64 self._store().close() raw = base64.b64decode(self.vault.data[self.account.cache_key_ref()]) self.assertEqual(len(raw), 32) def test_second_store_reuses_the_stored_key(self): first = self._store() fid = first.upsert_folder("INBOX") first.upsert_messages(fid, [meta(1, subject="Devis")]) first.close() second = self._store() self.assertEqual(second.list_messages(fid)[0].subject, "Devis") second.close() def test_key_is_not_regenerated_on_reopen(self): self._store().close() stored = self.vault.data[self.account.cache_key_ref()] self._store().close() self.assertEqual(self.vault.data[self.account.cache_key_ref()], stored) def test_key_never_lands_in_the_cache_file(self): import base64 store = self._store() fid = store.upsert_folder("INBOX") store.upsert_messages(fid, [meta(1)]) root = store.root store.close() raw = base64.b64decode(self.vault.data[self.account.cache_key_ref()]) blob = (root / "cache.db").read_bytes() self.assertNotIn(raw, blob) self.assertNotIn(base64.b64encode(raw), blob) class TestThreadSafety(unittest.TestCase): """Le TUI synchronise dans un thread de travail pendant que l'écran lit. Sans `check_same_thread=False` ET le verrou, la toute première passe de synchronisation lèverait `sqlite3.ProgrammingError`. """ def setUp(self): self.tmp = tempfile.TemporaryDirectory() self.account = account_from_preset("perso", "a@x.ca", "generic") self.store = Store( self.account, mode="clear", base=Path(self.tmp.name) ) self.store.open() self.fid = self.store.upsert_folder("INBOX") def tearDown(self): self.store.close() self.tmp.cleanup() def test_read_from_another_thread(self): import threading erreurs = [] def worker(): try: self.store.folders() except Exception as exc: erreurs.append(f"{type(exc).__name__}: {exc}") thread = threading.Thread(target=worker) thread.start() thread.join() self.assertEqual(erreurs, []) def test_write_from_another_thread(self): import threading erreurs = [] def worker(): try: self.store.upsert_messages(self.fid, [meta(1)]) except Exception as exc: erreurs.append(f"{type(exc).__name__}: {exc}") thread = threading.Thread(target=worker) thread.start() thread.join() self.assertEqual(erreurs, []) self.assertEqual(len(self.store.list_messages(self.fid)), 1) def test_concurrent_writers_all_land(self): """Le verrou sérialise : aucun upsert ne doit se perdre.""" import threading def worker(start): self.store.upsert_messages( self.fid, [meta(uid) for uid in range(start, start + 20)] ) threads = [ threading.Thread(target=worker, args=(base,)) for base in (1, 101, 201, 301) ] for thread in threads: thread.start() for thread in threads: thread.join() self.assertEqual( len(self.store.list_messages(self.fid, limit=500)), 80 ) class TestKeyRequired(unittest.TestCase): def test_encrypted_without_key_or_secrets_raises(self): with tempfile.TemporaryDirectory() as tmp: acc = account_from_preset("perso", "a@x.ca", "generic") store = Store(acc, mode="encrypted", base=Path(tmp)) with self.assertRaises(StoreError): store.open() if __name__ == "__main__": unittest.main()