erplibre/test/test_mail_secrets.py
Mathieu Benoit cfbdd9406e [REF] format : passer l'outillage et les tests sous ruff
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
2026-09-24 14:40:38 -04:00

237 lines
8.5 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 os
import stat
import tempfile
import unittest
from unittest.mock import MagicMock, patch
from script.todo.mail.secrets import (
SecretError,
SecretStore,
create_kdbx,
keyring_is_safe,
)
class FakeKeyringBackend:
"""Imite un backend keyring, sans toucher au trousseau de la machine."""
def __init__(self, name="keyring.backends.SecretService.Keyring"):
self.__class__.__module__ = name.rsplit(".", 1)[0]
self._name = name
self.store = {}
class TestKeyringSafety(unittest.TestCase):
def _with_backend(self, module_name, class_name):
backend = MagicMock()
type(backend).__module__ = module_name
type(backend).__qualname__ = class_name
return backend
def test_secretservice_is_safe(self):
backend = self._with_backend(
"keyring.backends.SecretService", "Keyring"
)
with patch("keyring.get_keyring", return_value=backend):
self.assertTrue(keyring_is_safe())
def test_macos_is_safe(self):
backend = self._with_backend("keyring.backends.macOS", "Keyring")
with patch("keyring.get_keyring", return_value=backend):
self.assertTrue(keyring_is_safe())
def test_windows_is_safe(self):
backend = self._with_backend(
"keyring.backends.Windows", "WinVaultKeyring"
)
with patch("keyring.get_keyring", return_value=backend):
self.assertTrue(keyring_is_safe())
def test_plaintext_alt_is_refused(self):
backend = self._with_backend("keyrings.alt.file", "PlaintextKeyring")
with patch("keyring.get_keyring", return_value=backend):
self.assertFalse(keyring_is_safe())
def test_fail_backend_is_refused(self):
backend = self._with_backend("keyring.backends.fail", "Keyring")
with patch("keyring.get_keyring", return_value=backend):
self.assertFalse(keyring_is_safe())
def test_unknown_backend_is_refused(self):
"""Par défaut on refuse : un backend qu'on ne connaît pas peut écrire en clair."""
backend = self._with_backend("un.paquet.inconnu", "Keyring")
with patch("keyring.get_keyring", return_value=backend):
self.assertFalse(keyring_is_safe())
class TestCreateKdbxPermissions(unittest.TestCase):
"""`create_database` (pykeepass) écrit d'abord un fichier `.tmp` via
`construct`, avec `open(filename, "w+b")` — donc à l'umask du process —
avant de le déplacer sur la cible. Resserrer l'umask le temps de l'appel
est donc la seule façon de fermer cette fenêtre : un `os.open` sur la
cible ne verrait jamais ce fichier intermédiaire."""
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.path = os.path.join(self.tmp.name, "nested", "test.kdbx")
def tearDown(self):
self.tmp.cleanup()
def test_file_is_0600(self):
create_kdbx(self.path, "motdepasse")
mode = stat.S_IMODE(os.stat(self.path).st_mode)
self.assertEqual(mode, 0o600)
def test_parent_dir_is_0700(self):
create_kdbx(self.path, "motdepasse")
mode = stat.S_IMODE(os.stat(os.path.dirname(self.path)).st_mode)
self.assertEqual(mode, 0o700)
def test_restores_the_process_umask(self):
before = os.umask(0o022)
os.umask(before) # `os.umask` ne peut que remplacer : on relit puis
# on rétablit exactement ce qu'on avait, sans jamais l'avoir changé
# pour de vrai entre les deux appels.
create_kdbx(self.path, "motdepasse")
after = os.umask(before)
os.umask(after)
self.assertEqual(after, before)
def test_umask_is_tightened_while_the_file_is_built(self):
"""La preuve directe : PENDANT `create_database`, l'umask doit être
resserré, sinon le fichier `.tmp` intermédiaire existe, même
brièvement, à l'umask permissif du process."""
seen = {}
def fake_create_database(path, password=None):
seen["umask"] = os.umask(0)
os.umask(seen["umask"])
with open(path, "wb"):
pass
with patch(
"pykeepass.create_database", side_effect=fake_create_database
):
create_kdbx(self.path, "motdepasse")
self.assertEqual(seen["umask"], 0o077)
class TestKdbxRoundtrip(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.path = os.path.join(self.tmp.name, "test.kdbx")
create_kdbx(self.path, "motdepasse")
from pykeepass import PyKeePass
self.kp = PyKeePass(self.path, password="motdepasse")
manager = MagicMock()
manager.get_kdbx.return_value = self.kp
self.store = SecretStore(kdbx_manager=manager, use_keyring=False)
def tearDown(self):
self.tmp.cleanup()
def test_created_file_exists(self):
self.assertTrue(os.path.exists(self.path))
def test_set_then_get(self):
self.store.set("kdbx:ERPLibre/Mail/perso", "hunter2")
self.assertEqual(self.store.get("kdbx:ERPLibre/Mail/perso"), "hunter2")
def test_set_creates_nested_groups(self):
self.store.set("kdbx:ERPLibre/Mail/travail", "s3cr3t")
groups = [g.name for g in self.kp.groups]
self.assertIn("ERPLibre", groups)
self.assertIn("Mail", groups)
def test_set_twice_overwrites(self):
self.store.set("kdbx:ERPLibre/Mail/perso", "ancien")
self.store.set("kdbx:ERPLibre/Mail/perso", "nouveau")
self.assertEqual(self.store.get("kdbx:ERPLibre/Mail/perso"), "nouveau")
def test_get_missing_returns_none(self):
self.assertIsNone(self.store.get("kdbx:ERPLibre/Mail/absent"))
def test_delete(self):
self.store.set("kdbx:ERPLibre/Mail/perso", "hunter2")
self.store.delete("kdbx:ERPLibre/Mail/perso")
self.assertIsNone(self.store.get("kdbx:ERPLibre/Mail/perso"))
def test_binary_key_survives_base64(self):
"""La clé de cache est stockée en base64 : 32 octets bruts doivent revenir intacts."""
import base64
raw = bytes(range(32))
self.store.set(
"kdbx:ERPLibre/Mail/perso/cache-key",
base64.b64encode(raw).decode(),
)
got = self.store.get("kdbx:ERPLibre/Mail/perso/cache-key")
self.assertEqual(base64.b64decode(got), raw)
class TestKeyringBranch(unittest.TestCase):
def setUp(self):
self.store = SecretStore(kdbx_manager=None, use_keyring=True)
def test_set_and_get_through_keyring(self):
vault = {}
with (
patch(
"script.todo.mail.secrets.keyring_is_safe", return_value=True
),
patch(
"keyring.set_password",
side_effect=lambda s, u, p: vault.__setitem__((s, u), p),
),
patch(
"keyring.get_password",
side_effect=lambda s, u: vault.get((s, u)),
),
):
self.store.set("keyring:perso", "hunter2")
self.assertEqual(self.store.get("keyring:perso"), "hunter2")
def test_refuses_unsafe_backend(self):
# `keyring.get_keyring` est patché AUSSI : le message d'erreur passe par
# keyring_backend_name(), qui interrogerait sinon le vrai trousseau.
with (
patch(
"script.todo.mail.secrets.keyring_is_safe", return_value=False
),
patch("keyring.get_keyring", return_value=MagicMock()),
):
with self.assertRaises(SecretError) as ctx:
self.store.set("keyring:perso", "hunter2")
# Traduit : on compare à la clé i18n elle-même, pas au mot français,
# pour que le test suive la langue active plutôt que de la figer.
from script.todo.todo_i18n import t
self.assertIn(t("mail_err_keyring_plaintext"), str(ctx.exception))
class TestRefParsing(unittest.TestCase):
def setUp(self):
self.store = SecretStore(kdbx_manager=None, use_keyring=False)
def test_unknown_scheme_raises(self):
with self.assertRaises(SecretError):
self.store.get("magique:perso")
def test_missing_scheme_raises(self):
with self.assertRaises(SecretError):
self.store.get("perso")
def test_no_backend_available_raises(self):
with self.assertRaises(SecretError):
self.store.set("keyring:perso", "x")
if __name__ == "__main__":
unittest.main()