Files
enterprise-addons/l10n_co_dian/tests/test_dian_misc.py
T

192 lines
13 KiB
Python

import requests
from lxml import etree
import re
from unittest.mock import patch, Mock
from odoo import Command
from odoo.tests import tagged, freeze_time
from odoo.addons.l10n_co_dian import xml_utils
from .common import TestCoDianCommon
@freeze_time('2024-01-30')
@tagged('post_install_l10n', 'post_install', '-at_install')
class TestDianMisc(TestCoDianCommon):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.invoice = cls._create_move()
def test_cufe1_generation(self):
# Invoice -> CUFE
xml = self._generate_xml(self.invoice)
self.env['l10n_co_dian.document']._create_document(xml, self.invoice, state='invoice_accepted')
self.assertEqual(
self.invoice.l10n_co_edi_cufe_cude_ref,
"7e08eb9e1c18c85cb77dd46d2000c34b553eb46d270622b74b43401b3bfb2c6875ca88e605ab8dc854cce97002fa77fa",
)
def test_cufe2_generation(self):
# Credit Note -> CUFE
credit_note = self._create_move(
move_type='out_refund',
l10n_co_edi_description_code_credit='1',
)
xml = self._generate_xml(credit_note)
self.env['l10n_co_dian.document']._create_document(xml, credit_note, state='invoice_accepted')
self.assertEqual(
credit_note.l10n_co_edi_cufe_cude_ref,
"863ddd1dfab6cd66de87c8780a5bfcf29baca8424b50986bd26660df6dcd3d453cd4c2e67c3a6415b01ebcbf8c903961",
)
def test_cude_generation(self):
# Debit Note -> CUDE
debit_note = self._create_move(
journal_id=self.debit_note_journal.id,
l10n_co_edi_description_code_debit='1',
)
self.assertTrue(debit_note.l10n_co_edi_debit_note)
xml = self._generate_xml(debit_note)
self.env['l10n_co_dian.document']._create_document(xml, debit_note, state='invoice_accepted')
self.assertEqual(
debit_note.l10n_co_edi_cufe_cude_ref,
"33d6543a3ab0fbcc6df45c2b3832000718f93e00a45e7b2083065f3a4edb54845d6b988467f66e5f7943cd618c56e83d",
)
def test_cuds_generation(self):
# Support Document -> CUDS
bill = self._create_move(
move_type='in_invoice',
journal_id=self.support_document_journal.id,
invoice_date=self.frozen_today,
)
self.assertTrue(bill.l10n_co_edi_is_support_document)
xml = self._generate_xml(bill)
self.env['l10n_co_dian.document']._create_document(xml, bill, state='invoice_accepted')
self.assertEqual(
bill.l10n_co_edi_cufe_cude_ref,
"0e71712630ebff53c24ffcdb15d52546d82919c15a840472ba080063c94c9a14746905c61494e15579b0b1b4f263dd6f",
)
def test_dian_soap_headers(self):
"""
Dian requires the SOAP headers for any request to be hashed and signed using the private key.
It is very sensitive to any change in the SOAP headers (namespaces, spaces, \n, etc).
"""
expected_request = '<soap:Envelope xmlns:soap="http://www.w3.org/2003/05/soap-envelope" xmlns:wcf="http://wcf.dian.colombia"><soap:Header xmlns:wsa="http://www.w3.org/2005/08/addressing"><wsse:Security xmlns:wsu="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd" xmlns:wsse="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd"><wsu:Timestamp><wsu:Created>2024-01-30T00:00:00.000Z</wsu:Created><wsu:Expires>2024-01-30T16:40:00.000Z</wsu:Expires></wsu:Timestamp><wsse:BinarySecurityToken EncodingType="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-soap-message-security-1.0#Base64Binary" ValueType="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-x509-token-profile-1.0#X509v3" wsu:Id="X509-bd9c66b3-ad3c-2d6d-1a3d-1fa7bc8960a9">MIIDhzCCAm+gAwIBAgIUJGk07XoDBiRCszEASOoN+L3udZAwDQYJKoZIhvcNAQELBQAwUzENMAsGA1UEAwwET2RvbzERMA8GA1UECAwIV2FsbG9uaWExEjAQBgNVBAcMCVJhbWlsbGllczENMAsGA1UECgwET2RvbzEMMAoGA1UECwwDUiZEMB4XDTIzMTAyNDE0NTMwMVoXDTMzMTAyMTE0NTMwMVowUzENMAsGA1UEAwwET2RvbzERMA8GA1UECAwIV2FsbG9uaWExEjAQBgNVBAcMCVJhbWlsbGllczENMAsGA1UECgwET2RvbzEMMAoGA1UECwwDUiZEMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA1gO1/MhpsIgHhx876mX53HIMQZozcu3/t6QVmMkfZN9mjvO2jpQgL5jVy+5CWeCuF0RnUbSHkeEXkZexVwSb0ylbGYSFC6Y8s5rzdTxMKGQnZ1sSKF1xpBiA1uWM+A//uxnnpxAF1XFhhXJWmhkAvgPusHzYjbk7rbP8Z5YYWcHAJUZTMRueEgnfSES4dRyZiz685XnGYX5q1qRlLN6FbAeqJmngFi30wA4YCtc0N3NOVtpoa64JktJ0XYLCYR9uvKDjk3w0EmuSKDBlC4RuvNZIw3vSXwMw45sZLu1e3W82bdX+/MFslDRiVZ37WFpLv++VEJIiIdXHQR28ibdyDQIDAQABo1MwUTAdBgNVHQ4EFgQUpBLh75v2a/Ar52sjKYj4GWK4WnswHwYDVR0jBBgwFoAUpBLh75v2a/Ar52sjKYj4GWK4WnswDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAT+E6bkiyxzPPcgu3af2/ozwst26vdSjc6KAhxTwVCjq6nU9nRkCF+HZAXViFmMeo7iVKisckmUwJbyMG0//OeGSi3EUX9p1yHe7r2iDpUqUxriLdx2sIUzR7DgwCEbuDgAs5ycndk40qlHUP/vnKOSX1v3n+Crrn9dYCUoKBEOckV/3ZR+pXyqTMFD8KRb9KqQm91iqHTZjTSzhL2dCRw+3dC1bCTeOOO+VG5EvBv3UAymnl/58S47K3juxZtO24wRdXxH2d/MtiCzr9P6riw21JfGvtZfajfDwc26YIk7uJuT4ih56K7vUZWUtyroy9h3tzqqIImWUjtzemINTrHQ==</wsse:BinarySecurityToken><ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#"><ds:SignedInfo><ds:CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"><ec:InclusiveNamespaces xmlns:ec="http://www.w3.org/2001/10/xml-exc-c14n#" PrefixList="wsa soap wcf"/></ds:CanonicalizationMethod><ds:SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"/><ds:Reference URI="#id-972a8469-1641-9f82-8b9d-2434e465e150"><ds:Transforms><ds:Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"><ec:InclusiveNamespaces xmlns:ec="http://www.w3.org/2001/10/xml-exc-c14n#" PrefixList="soap wcf"/></ds:Transform></ds:Transforms><ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/><ds:DigestValue>fv6JrbE+eDnMIKLfH6+rsQWwPzYvC4vx9bNEvNC2UT0=</ds:DigestValue></ds:Reference></ds:SignedInfo><ds:SignatureValue>HS3+vdJMmH07OCnh6dgW6rMoNPR4Lu2Sr7+Nt8uRukBkY0Zm7VOT8lvCHC8WD+1F130dUvPKkIe5\nZBjxgZqKXJEM4djInu4L4dFKsd3fvETMWnOsRX/QdfE+euUNvblib6PGzJBeFvrg42RCWCNWMg8s\nf9/2crjXTXdXA80YcYo6sW+TXclP/BC4f2OEW07RNF55Kq9CQt1f3lFG7npC9+DEUznhTIsE56Lf\n1JGsxK2HH0JGapv026GZ8I4KT/FR3NSiEdumiK2zSh32W5CGdWN1FFBepc+OJai+2cSCCX0IxQRB\n6g6k4zvWttE09GlFFjfsaLxdPom6JFI7jdmajA==\n</ds:SignatureValue><ds:KeyInfo><wsse:SecurityTokenReference><wsse:Reference ValueType="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-x509-token-profile-1.0#X509v3" URI="#X509-bd9c66b3-ad3c-2d6d-1a3d-1fa7bc8960a9"/></wsse:SecurityTokenReference></ds:KeyInfo></ds:Signature></wsse:Security><wsa:Action>http://wcf.dian.colombia/IWcfDianCustomerServices/SendBillSync</wsa:Action><wsa:To xmlns:wsu="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd" wsu:Id="id-972a8469-1641-9f82-8b9d-2434e465e150">https://vpfe-hab.dian.gov.co/WcfDianCustomerServices.svc</wsa:To></soap:Header><soap:Body><wcf:SendBillSync><wcf:fileName>invoice.zip</wcf:fileName><wcf:contentFile>test</wcf:contentFile></wcf:SendBillSync></soap:Body></soap:Envelope>'
expected = etree.tostring(etree.fromstring(expected_request), method='c14n')
def post(url, data=None, json=None, **kwargs):
actual_request = etree.fromstring(data.decode())
actual_request.find('.//{*}contentFile').text = 'test' # remove the payload to shorten the request
actual = etree.tostring(actual_request, method='c14n') # canonize to prevent undeterministic order of the namespaces declarations
self.assertEqual(expected, actual)
response = Mock(spec=requests.Response)
response.status_code = 200
response.text = '<A><IsValid>true</IsValid></A>'
return response
with self._mock_get_status(), patch('requests.post', side_effect=post), self._mock_uuid_generation(), self._disable_get_acquirer_call():
self.env['account.move.send.wizard'] \
.with_context(active_model=self.invoice._name, active_ids=self.invoice.ids) \
.create({}) \
.action_send_and_print()
def test_dian_payload(self):
"""
As for the SOAP headers, Dian requires the invoice xml (the payload) to be hashed and signed using the private
key. It is very sensitive to any change (namespaces, spaces, \n, etc).
In case of error, validator will raise: "ZE02, Rechazo: Valor de la firma inválido."
"""
with self._mock_uuid_generation():
xml = self._generate_xml(self.invoice)
# the namespaces 'sts' and 'ext' should be declared on the root node otherwise DIAN raises
# "Namespace prefix not defined"
root = etree.fromstring(xml)
self.assertEqual(f'{{{root.nsmap[None]}}}Invoice', root.tag)
self.assertIn('sts', root.nsmap)
self.assertIn('ext', root.nsmap)
expected_xml = self._read_file('l10n_co_dian/tests/attachments/invoice_signed.xml', 'r')
# Remove the namespaces as they are serialized in a non deterministic order
self.assertEqual(
re.sub(r'xmlns.*"', r'', expected_xml.strip()),
re.sub(r'xmlns.*"', r'', xml.strip().decode()),
)
def test_dian_xades_signature(self):
""" Check the XMLDsig digests and the XAdES digests and signature """
xml = self._read_file('l10n_co_dian/tests/attachments/invoice_signed.xml', 'rb')
root = etree.fromstring(xml)
# Nullify the XMLDsig digests
expected_digests = []
for node in root.findall(".//ds:DigestValue", root.nsmap)[:3]:
expected_digests.append(node.text)
node.text = ''
# Recompute the XMLDsig digests
xml_utils._reference_digests(root.find(".//ds:SignedInfo", root.nsmap))
actual_digests = [node.text for node in root.findall(".//ds:DigestValue", root.nsmap)[:3]]
self.assertEqual(expected_digests, actual_digests)
# Recompute the DigestValue for the X509 certificate
expected_x509_digest = self.certificate_demo._get_fingerprint_bytes(formatting='base64').decode()
self.assertEqual(expected_x509_digest, root.find(".//{*}CertDigest/{*}DigestValue").text)
# Recompute the X509 certificate
obtained_x509 = self.certificate_demo._get_der_certificate_bytes().decode()
self.assertEqual(obtained_x509, root.find(".//{*}X509Certificate").text)
# Recompute the XAdES signature
expected_sig_value = root.find('.//{*}SignatureValue').text
root.find('.//{*}SignatureValue').text = ''
xml_utils._fill_signature(root.find(".//ds:Signature", root.nsmap), self.certificate_demo)
self.assertEqual(root.find('.//{*}SignatureValue').text, expected_sig_value)
def test_state_and_city(self):
""" Check that the colombian codes is correctly padded (length 5 for cities, length 2 for states) """
self.invoice.partner_id.city_id = self.env.ref('l10n_co_edi.city_co_01')
self.invoice.partner_id.state_id = self.invoice.partner_id.city_id.state_id
xml = self._generate_xml(self.invoice)
root = etree.fromstring(xml)
self.assertEqual(
root.find('.//{*}AccountingCustomerParty//{*}RegistrationAddress/{*}CountrySubentityCode').text,
'05',
)
self.assertEqual(
root.find('.//{*}AccountingCustomerParty//{*}Address/{*}ID').text,
'05001',
)
def test_attached_document_invoice_flow(self):
self.invoice.partner_id.email = "tmp@odoo.com" # so the email is sent by default when Send & Printing
# Send & Print to generate the zip (containing the AttachedDocument + the PDF)
with self._mock_get_status():
self._mock_send_and_print(move=self.invoice, response_file='SendBillSync_warnings.xml')
attachments = self.env['ir.attachment'].search([
('res_model', '=', self.invoice._name),
('res_id', '=', self.invoice.id),
])
self.assertIn("SETP202400001.zip", attachments.mapped('name'))
# regenerate the attached document
with self._mock_get_status():
xml, error = self.invoice.l10n_co_dian_document_ids._get_attached_document()
self.assertEqual(error, "")
self._assert_document_dian(xml, "l10n_co_dian/tests/attachments/attached_document.xml")
def test_account_manager_update_journal(self):
"""
This test serves both purposes of checking the DIAN configuration reload
and making sure an account manager has all access needed in this flow
"""
self.user.group_ids = [Command.unlink(self.env.ref('base.group_system').id)]
journal = self.support_document_journal
message = self._mock_button_l10n_co_dian_fetch_numbering_range(journal=journal, response_file='GetNumberingRange_journal.xml')
self.assertEqual(message['params']['message'], 'The journal values were successfully updated.')