Files
odoo_account_ebics/account_ebics/tests/test_ebics_userid.py
T
Jérémy Didderen a1fc381121 [IMP] account_ebics - Add unit tests
Add 97 tests:

- account_ebics: order numbers, camt and CFONB split per account,
  journal lookup, duplicate statements, download and upload wizards,
  key initialisation (INI/HIA/HPB), passphrase change, admin orders.
- account_ebics_batch: scheduled import, import log, reprocess.
- account_ebics_payment_order / account_ebics_batch_payment: upload of
  the payment file and link to the payment document.
- account_ebics_statement_import_test (new, test-only, not to install
  in production): installs the OCA camt and CFONB parsers in CI and
  runs the end-to-end statement import.

The bank is simulated by patching ebics.xfer._setup_client, or the
fintech client classes for the key initialisation; keys, certificates
and the INI letter are real, created in a temporary directory.
2026-10-27 23:14:34 +01:00

285 lines
11 KiB
Python

# Copyright 2026 Noviat.
# License LGPL-3 or later (https://www.gnu.org/licenses/lgpl).
import base64
import gc
import itertools
import os
import shutil
import tempfile
from unittest.mock import patch
from urllib.error import URLError
from fintech.ebics import EbicsFunctionalError, EbicsKeyRing, EbicsTechnicalError
from odoo import Command, fields
from odoo.exceptions import UserError
from odoo.tests import tagged
from odoo.tools import mute_logger
from odoo.addons.account_ebics.models import ebics_userid as ebics_userid_module
from .common import EbicsTestCommon
# Throwaway passphrase for keys created in a temporary directory.
WRONG_PHRASE = "wrong-passphrase"
PUBLIC_BANK_KEYS = (
"Public bank keys (simulated)\nX002 hash: 00 11 22\nE002 hash: 33 44 55\n"
)
def fake_init_client(hev=None, ini_error=None, hpb_error=None):
"""Return a stand-in for fintech's EbicsClient covering the bank side of
the key ceremony. Keys, certificates and the INI letter stay real."""
class FakeInitClient:
calls = []
def __init__(self, bank, user, version=None):
self.bank = bank
def HEV(self):
self.calls.append("HEV")
return hev if hev is not None else {"H004": "2.5", "H005": "3.0"}
def _order(self, name):
self.calls.append(name)
return self.bank._next_order_id("PARTNER1")
def INI(self):
if ini_error:
raise ini_error
return self._order("INI")
def HIA(self):
return self._order("HIA")
def HPB(self):
if hpb_error:
raise hpb_error
self.calls.append("HPB")
return PUBLIC_BANK_KEYS
return FakeInitClient
class EbicsKeysCommon(EbicsTestCommon):
"""Real keys in a temporary directory, simulated bank."""
_userid_seq = itertools.count(1)
@classmethod
def setUpClass(cls):
super().setUpClass()
keys_dir = tempfile.mkdtemp(prefix="ebics_keys_test_")
cls.addClassCleanup(shutil.rmtree, keys_dir, ignore_errors=True)
cls.ebics_config.write({"ebics_keys": keys_dir, "ebics_key_bitlength": 2048})
cls.bank_fr = cls.env["res.bank"].create(
{"name": "Banque Test", "country": cls.env.ref("base.fr").id}
)
cls.journal = cls._create_bank_journal("TKEY", "FR7630004000031234567890143")
cls.journal.bank_account_id.sudo().bank_id = cls.bank_fr
def _new_userid(self, **vals):
# A new name per test: fintech keeps open keyrings per file in-process.
return self.env["ebics.userid"].create(
{
"name": f"KEYUSER{next(self._userid_seq)}",
"ebics_config_id": self.ebics_config.id,
"user_ids": [Command.set(self.env.user.ids)],
"company_ids": [Command.set(self.company.ids)],
"ebics_passphrase": "init-passphrase",
**vals,
}
)
def _init_1(self, userid, client_class=None):
client_class = client_class or fake_init_client()
with patch.object(ebics_userid_module, "EbicsClient", client_class):
userid.ebics_init_1()
return client_class
@tagged("post_install", "-at_install")
class TestKeyCeremony(EbicsKeysCommon):
def test_full_ceremony(self):
userid = self._new_userid(ebics_key_x509=True, ebics_key_x509_dn_cn="Test")
client_class = self._init_1(userid)
self.assertEqual(client_class.calls, ["HEV", "INI", "HIA"])
self.assertEqual(userid.state, "init")
self.assertTrue(os.path.isfile(userid.ebics_keys_fn))
self.assertTrue(userid.ebics_keys_found)
self.assertTrue(base64.b64decode(userid.ebics_ini_letter).startswith(b"%PDF"))
today = fields.Date.today().isoformat()
self.assertEqual(userid.ebics_ini_letter_fn, f"TESTHOST_ini_letter_{today}.pdf")
userid.ebics_init_2()
self.assertEqual(userid.state, "get_bank_keys")
with patch.object(ebics_userid_module, "EbicsClient", client_class):
userid.ebics_init_3()
self.assertEqual(userid.state, "to_verify")
self.assertEqual(
base64.b64decode(userid.ebics_public_bank_keys).decode(), PUBLIC_BANK_KEYS
)
self.assertEqual(
userid.ebics_public_bank_keys_fn, f"TESTHOST_public_bank_keys_{today}.txt"
)
bank_class = ebics_userid_module.EbicsBank
with patch.object(bank_class, "activate_keys") as activate_keys:
userid.ebics_init_4()
activate_keys.assert_called_once()
self.assertEqual(userid.state, "active_keys")
# stored passphrase is kept
self.assertEqual(userid.ebics_passphrase, "init-passphrase")
def test_passphrase_not_stored_is_cleared(self):
userid = self._new_userid(ebics_passphrase_store=False)
self._init_1(userid)
self.assertFalse(userid.ebics_passphrase)
def test_existing_keys_are_kept(self):
"""A second init reuses the key file: reset tools must delete it."""
userid = self._new_userid()
self._init_1(userid)
with open(userid.ebics_keys_fn, "rb") as f:
keys = f.read()
userid.set_to_draft()
self._init_1(userid)
with open(userid.ebics_keys_fn, "rb") as f:
self.assertEqual(f.read(), keys)
def test_step_guards(self):
userid = self._new_userid()
userid.state = "init"
with self.assertRaisesRegex(UserError, "draft"):
userid.ebics_init_1()
userid.state = "draft"
for method in ("ebics_init_2", "ebics_init_3", "ebics_init_4"):
with self.subTest(method=method), self.assertRaises(UserError):
getattr(userid, method)()
def test_init_requires_passphrase(self):
userid = self._new_userid(ebics_passphrase=False)
with self.assertRaisesRegex(UserError, "passphrase"):
userid.ebics_init_1()
def test_3skey_requires_certificate(self):
userid = self._new_userid(swift_3skey=True)
with self.assertRaisesRegex(UserError, "3SKey certificate"):
userid.ebics_init_1()
def test_init_requires_a_journal_with_bank(self):
self.ebics_config.journal_ids = False
userid = self._new_userid()
with self.assertRaisesRegex(UserError, "No bank defined"):
self._init_1(userid)
def test_version_not_supported_by_bank(self):
userid = self._new_userid()
with self.assertRaisesRegex(UserError, "version mismatch"):
self._init_1(userid, fake_init_client(hev={"H003": "2.4"}))
self.assertEqual(userid.state, "draft")
def test_init_errors(self):
cases = [
(URLError("connection refused"), "urlopen error"),
(
EbicsFunctionalError("091002", "EBICS_INVALID_USER_OR_USER_STATE"),
"091002",
),
(EbicsTechnicalError("061001", "EBICS_AUTHENTICATION_FAILED"), "061001"),
]
for error, expected in cases:
with self.subTest(error=type(error).__name__):
userid = self._new_userid()
with (
mute_logger(ebics_userid_module.__name__),
self.assertRaisesRegex(UserError, expected),
):
self._init_1(userid, fake_init_client(ini_error=error))
def test_get_bank_keys_error(self):
userid = self._new_userid()
self._init_1(userid)
userid.ebics_init_2()
error = EbicsFunctionalError("091002", "EBICS_INVALID_USER_OR_USER_STATE")
client_class = fake_init_client(hpb_error=error)
with (
patch.object(ebics_userid_module, "EbicsClient", client_class),
self.assertRaisesRegex(UserError, "EBICS Functional Error"),
):
userid.ebics_init_3()
def test_h003_order_numbers(self):
self.ebics_config.write({"ebics_version": "H003", "order_number": "A000"})
userid = self._new_userid()
self._init_1(userid, fake_init_client(hev={"H003": "2.4"}))
# INI uses A000, HIA uses A001
self.assertEqual(self.ebics_config.order_number, "A002")
def test_ini_letter_language(self):
calls = []
class SpyUser(ebics_userid_module.EbicsUser):
def create_ini_letter(self, bankname, lang=None, **kwargs):
calls.append((bankname, lang))
return b"%PDF-1.4 spy"
cases = [
("base.fr", "fr_FR", "fr"),
("base.de", "fr_FR", "de"),
("base.be", "fr_FR", "fr"),
("base.be", "en_US", "en"),
]
for country, user_lang, expected in cases:
with self.subTest(country=country, user_lang=user_lang):
lang = (
self.env["res.lang"]
.sudo()
.with_context(active_test=False)
.search([("code", "=", user_lang)])
)
lang.active = True
self.bank_fr.country = self.env.ref(country)
self.env.user.lang = user_lang
userid = self._new_userid()
with patch.object(ebics_userid_module, "EbicsUser", SpyUser):
self._init_1(userid)
self.assertEqual(calls[-1], ("Banque Test", expected))
def test_key_renewal_requires_draft_config(self):
userid = self._new_userid()
with self.assertRaisesRegex(UserError, "Key Renewal"):
userid.set_to_get_bank_keys()
self.ebics_config.set_to_draft()
userid.set_to_get_bank_keys()
self.assertEqual(userid.state, "get_bank_keys")
def test_h005_requires_x509(self):
self.ebics_config.ebics_version = "H005"
with self.assertRaisesRegex(UserError, "X.509"):
self._new_userid(ebics_key_x509=False)
def test_stored_passphrase_is_checked(self):
userid = self._new_userid()
self._init_1(userid)
gc.collect() # no other keyring instance: fintech raises ValueError
userid.ebics_passphrase = WRONG_PHRASE
with self.assertRaisesRegex(UserError, "Passphrase mismatch"):
userid._onchange_ebics_passphrase_store()
def test_stored_passphrase_is_checked_with_open_keyring(self):
"""In a worker the keyring may already be open with the right
passphrase: fintech then raises RuntimeError."""
userid = self._new_userid()
self._init_1(userid)
open_keyring = EbicsKeyRing(
keys=userid.ebics_keys_fn, passphrase=userid.ebics_passphrase
)
userid.ebics_passphrase = WRONG_PHRASE
with self.assertRaisesRegex(UserError, "Passphrase mismatch"):
userid._onchange_ebics_passphrase_store()
del open_keyring