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
120 lines
3.7 KiB
Python
120 lines
3.7 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 unittest
|
|
|
|
from script.todo.mail.crypto import (
|
|
CLEAR_MAGIC,
|
|
SEALED_MAGIC,
|
|
AesGcmCrypto,
|
|
CryptoError,
|
|
NullCrypto,
|
|
build_crypto,
|
|
new_key,
|
|
)
|
|
|
|
|
|
class TestNullCrypto(unittest.TestCase):
|
|
def test_roundtrip(self):
|
|
box = NullCrypto()
|
|
self.assertEqual(box.open(box.seal(b"bonjour")), b"bonjour")
|
|
|
|
def test_envelope_is_marked_clear(self):
|
|
self.assertTrue(NullCrypto().seal(b"x").startswith(CLEAR_MAGIC))
|
|
|
|
def test_cannot_open_sealed_blob(self):
|
|
blob = AesGcmCrypto(new_key()).seal(b"secret")
|
|
with self.assertRaises(CryptoError):
|
|
NullCrypto().open(blob)
|
|
|
|
|
|
class TestAesGcmCrypto(unittest.TestCase):
|
|
def setUp(self):
|
|
self.key = new_key()
|
|
|
|
def test_key_is_32_bytes(self):
|
|
self.assertEqual(len(self.key), 32)
|
|
|
|
def test_roundtrip(self):
|
|
box = AesGcmCrypto(self.key)
|
|
self.assertEqual(box.open(box.seal(b"bonjour")), b"bonjour")
|
|
|
|
def test_envelope_is_marked_sealed(self):
|
|
self.assertTrue(
|
|
AesGcmCrypto(self.key).seal(b"x").startswith(SEALED_MAGIC)
|
|
)
|
|
|
|
def test_ciphertext_hides_plaintext(self):
|
|
blob = AesGcmCrypto(self.key).seal(b"sujet confidentiel")
|
|
self.assertNotIn(b"confidentiel", blob)
|
|
|
|
def test_nonce_differs_each_call(self):
|
|
box = AesGcmCrypto(self.key)
|
|
self.assertNotEqual(box.seal(b"meme texte"), box.seal(b"meme texte"))
|
|
|
|
def test_wrong_key_raises(self):
|
|
blob = AesGcmCrypto(self.key).seal(b"secret")
|
|
with self.assertRaises(CryptoError):
|
|
AesGcmCrypto(new_key()).open(blob)
|
|
|
|
def test_tampered_blob_raises(self):
|
|
blob = bytearray(AesGcmCrypto(self.key).seal(b"secret"))
|
|
blob[-1] ^= 0xFF
|
|
with self.assertRaises(CryptoError):
|
|
AesGcmCrypto(self.key).open(bytes(blob))
|
|
|
|
def test_reads_clear_blob(self):
|
|
"""Une base écrite en clair reste lisible après passage en chiffré."""
|
|
clear = NullCrypto().seal(b"ancien")
|
|
self.assertEqual(AesGcmCrypto(self.key).open(clear), b"ancien")
|
|
|
|
def test_rejects_bad_key_length(self):
|
|
with self.assertRaises(CryptoError):
|
|
AesGcmCrypto(b"trop court")
|
|
|
|
def test_rejects_unknown_magic(self):
|
|
with self.assertRaises(CryptoError):
|
|
AesGcmCrypto(self.key).open(b"ZZdonnees")
|
|
|
|
def test_unexpected_error_is_not_disguised_as_a_bad_key(self):
|
|
"""Un bug de programmation doit remonter tel quel, pas en CryptoError."""
|
|
box = AesGcmCrypto(self.key)
|
|
blob = box.seal(b"secret")
|
|
|
|
class Boom:
|
|
# AESGCM est adossé à Rust : `decrypt` y est en lecture seule.
|
|
# On remplace donc l'objet entier, pas sa méthode.
|
|
def decrypt(self, *args, **kwargs):
|
|
raise RuntimeError("bug interne")
|
|
|
|
box._aes = Boom()
|
|
with self.assertRaises(RuntimeError):
|
|
box.open(blob)
|
|
|
|
|
|
class TestBuildCrypto(unittest.TestCase):
|
|
def test_clear_mode(self):
|
|
self.assertIsInstance(build_crypto("clear", None), NullCrypto)
|
|
|
|
def test_encrypted_mode(self):
|
|
self.assertIsInstance(
|
|
build_crypto("encrypted", new_key()), AesGcmCrypto
|
|
)
|
|
|
|
def test_ephemeral_mode(self):
|
|
self.assertIsInstance(
|
|
build_crypto("ephemeral", new_key()), AesGcmCrypto
|
|
)
|
|
|
|
def test_encrypted_without_key_raises(self):
|
|
with self.assertRaises(CryptoError):
|
|
build_crypto("encrypted", None)
|
|
|
|
def test_unknown_mode_raises(self):
|
|
with self.assertRaises(CryptoError):
|
|
build_crypto("magique", None)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|