erplibre/test/test_mail_store.py

637 lines
21 KiB
Python
Raw Normal View History

#!/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()