diff --git a/.bumpversion.cfg b/.bumpversion.cfg index c7ce0cb4..c60a93e2 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.5.1 +current_version = 0.6.0 commit = True tag = True diff --git a/HISTORY.rst b/HISTORY.rst index d0f42b4a..19fbeb1b 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -3,6 +3,17 @@ History ------- +0.6.0 (2019-05-08) ++++++++++++++++++++++++ + +Includes backwards-incompatible changes to data model ``DteDataL2``. + +* (PR #38, 2019-05-08) dte.data_models: alter field ``DteDataL2.signature_x509_cert_pem`` +* (PR #37, 2019-05-08) dte.data_models: alter field ``DteDataL2.firma_documento_dt_naive`` +* (PR #36, 2019-05-08) libs.crypto_utils: add functions +* (PR #35, 2019-05-07) libs.tz_utils: minor improvements +* (PR #34, 2019-05-06) docs: Fix `bumpversion` command + 0.5.1 (2019-05-03) +++++++++++++++++++++++ diff --git a/cl_sii/__init__.py b/cl_sii/__init__.py index 321f80ef..ffd087f7 100644 --- a/cl_sii/__init__.py +++ b/cl_sii/__init__.py @@ -5,4 +5,4 @@ """ -__version__ = '0.5.1' +__version__ = '0.6.0' diff --git a/cl_sii/dte/data_models.py b/cl_sii/dte/data_models.py index 4591cda5..a9044fd6 100644 --- a/cl_sii/dte/data_models.py +++ b/cl_sii/dte/data_models.py @@ -22,7 +22,7 @@ import cl_sii.contribuyente.constants import cl_sii.rut.constants -from cl_sii.libs import encoding_utils +from cl_sii.libs import tz_utils from cl_sii.rut import Rut from . import constants @@ -95,6 +95,13 @@ def validate_non_empty_bytes(value: bytes) -> None: raise ValueError("Bytes value length (stripped) is 0.") +def validate_correct_tz(value: datetime, tz: tz_utils.PytzTimezone) -> None: + if not tz_utils.dt_is_aware(value): + raise ValueError("Value must be a timezone-aware datetime.", value) + if value.tzinfo.zone != tz.zone: # type: ignore + raise ValueError(f"Timezone of datetime value must be '{tz.zone!s}'.", value) + + @dataclasses.dataclass(frozen=True) class DteNaturalKey: @@ -318,6 +325,16 @@ class DteDataL2(DteDataL1): """ + ########################################################################### + # constants + ########################################################################### + + DATETIME_FIELDS_TZ = tz_utils.TZ_CL_SANTIAGO + + ########################################################################### + # fields + ########################################################################### + emisor_razon_social: str = dc_field() """ "Razón social" (legal name) of the "emisor" of the DTE. @@ -333,7 +350,7 @@ class DteDataL2(DteDataL1): "Fecha de vencimiento (pago)" of the DTE. """ - firma_documento_dt_naive: Optional[datetime] = dc_field(default=None) + firma_documento_dt: Optional[datetime] = dc_field(default=None) """ Datetime on which the "documento" was digitally signed. """ @@ -343,11 +360,13 @@ class DteDataL2(DteDataL1): DTE's digital signature's value (raw bytes, without base64 encoding). """ - signature_x509_cert_pem: Optional[bytes] = dc_field(default=None) + signature_x509_cert_der: Optional[bytes] = dc_field(default=None) """ - DTE's digital signature's PEM-encoded X.509 cert. + DTE's digital signature's DER-encoded X.509 cert. - PEM-encoded implies base64-encoded. + .. seealso:: + Functions :func:`cl_sii.libs.crypto_utils.load_der_x509_cert` + and :func:`cl_sii.libs.crypto_utils.x509_cert_der_to_pem`. """ emisor_giro: Optional[str] = dc_field(default=None) @@ -386,9 +405,10 @@ def __post_init__(self) -> None: if not isinstance(self.fecha_vencimiento_date, date): raise TypeError("Inappropriate type of 'fecha_vencimiento_date'.") - if self.firma_documento_dt_naive is not None: - if not isinstance(self.firma_documento_dt_naive, datetime): - raise TypeError("Inappropriate type of 'firma_documento_dt_naive'.") + if self.firma_documento_dt is not None: + if not isinstance(self.firma_documento_dt, datetime): + raise TypeError("Inappropriate type of 'firma_documento_dt'.") + validate_correct_tz(self.firma_documento_dt, self.DATETIME_FIELDS_TZ) if self.signature_value is not None: if not isinstance(self.signature_value, bytes): @@ -396,12 +416,11 @@ def __post_init__(self) -> None: validate_clean_bytes(self.signature_value) validate_non_empty_bytes(self.signature_value) - if self.signature_x509_cert_pem is not None: - if not isinstance(self.signature_x509_cert_pem, bytes): - raise TypeError("Inappropriate type of 'signature_x509_cert_pem'.") - validate_clean_bytes(self.signature_x509_cert_pem) - validate_non_empty_bytes(self.signature_x509_cert_pem) - encoding_utils.validate_base64(self.signature_x509_cert_pem) + if self.signature_x509_cert_der is not None: + if not isinstance(self.signature_x509_cert_der, bytes): + raise TypeError("Inappropriate type of 'signature_x509_cert_der'.") + validate_clean_bytes(self.signature_x509_cert_der) + validate_non_empty_bytes(self.signature_x509_cert_der) if self.emisor_giro is not None: if not isinstance(self.emisor_giro, str): diff --git a/cl_sii/dte/parse.py b/cl_sii/dte/parse.py index 5cd5831f..d7a5f0ed 100644 --- a/cl_sii/dte/parse.py +++ b/cl_sii/dte/parse.py @@ -24,6 +24,7 @@ from typing import Tuple from cl_sii.libs import encoding_utils +from cl_sii.libs import tz_utils from cl_sii.libs import xml_utils from cl_sii.libs.xml_utils import XmlElement, XmlElementTree from cl_sii.rut import Rut @@ -447,11 +448,13 @@ def parse_dte_xml(xml_doc: XmlElement) -> data_models.DteDataL2: monto_total_value = int(monto_total_em.text.strip()) - tmst_firma_value = datetime.fromisoformat(tmst_firma_em.text) + tmst_firma_value = tz_utils.convert_naive_dt_to_tz_aware( + dt=datetime.fromisoformat(tmst_firma_em.text), + tz=data_models.DteDataL2.DATETIME_FIELDS_TZ) signature_signature_value = encoding_utils.decode_base64_strict( signature_signature_value_em.text.strip()) - signature_key_info_x509_cert_pem = encoding_utils.clean_base64( + signature_key_info_x509_cert_der = encoding_utils.decode_base64_strict( signature_key_info_x509_cert_em.text.strip()) return data_models.DteDataL2( @@ -464,9 +467,9 @@ def parse_dte_xml(xml_doc: XmlElement) -> data_models.DteDataL2: emisor_razon_social=emisor_razon_social_value, receptor_razon_social=receptor_razon_social_value, fecha_vencimiento_date=fecha_vencimiento_value, - firma_documento_dt_naive=tmst_firma_value, + firma_documento_dt=tmst_firma_value, signature_value=signature_signature_value, - signature_x509_cert_pem=signature_key_info_x509_cert_pem, + signature_x509_cert_der=signature_key_info_x509_cert_der, emisor_giro=emisor_giro_value, emisor_email=emisor_email_value, receptor_email=receptor_email_value, diff --git a/cl_sii/libs/crypto_utils.py b/cl_sii/libs/crypto_utils.py index 00452e1d..e9dee5b6 100644 --- a/cl_sii/libs/crypto_utils.py +++ b/cl_sii/libs/crypto_utils.py @@ -1,3 +1,43 @@ +""" +Crypto utils +============ + + +DER and PEM +----------- + +Best answer to the +`StackOverflow question `_ +"What are the differences between .pem, .cer and .der?". + +Best answer to the +`ServerFault question `_. +"What is a Pem file and how does it differ from other OpenSSL Generated Key File Formats?". + + +DER +-------- + +DER stands for "Distinguished Encoding Rules". + +> A way to encode ASN.1 syntax in binary. + +> The parent format of PEM. It's useful to think of it as a binary version +> of the base64-encoded PEM file. + +PEM +-------- + +PEM stands for "Privacy Enhanced Mail". + +> A failed method for secure email but the container format it used lives on, +> and is a base64 translation of the x509 ASN.1 keys. + +> In the case that it encodes a certificate it would simply contain the +> base64 encoding of the DER certificate [plus the header and footer]. + +""" +import base64 from typing import Union import cryptography.x509 @@ -6,10 +46,35 @@ from cryptography.x509 import Certificate as X509Cert from OpenSSL.crypto import X509 as _X509CertOpenSsl # noqa: F401 +from . import encoding_utils + + +def load_der_x509_cert(der_value: bytes) -> X509Cert: + """ + Load an X.509 certificate from DER-encoded certificate data. + + :raises TypeError: + :raises ValueError: + + """ + if not isinstance(der_value, bytes): + raise TypeError("Value must be bytes.") + + try: + x509_cert = cryptography.x509.load_der_x509_certificate( + data=der_value, + backend=_crypto_x509_backend) + except ValueError: + # e.g. + # "Unable to load certificate" + raise + + return x509_cert + def load_pem_x509_cert(pem_value: Union[str, bytes]) -> X509Cert: """ - Load an X.509 certificate from a PEM-formatted value. + Load an X.509 certificate from PEM-encoded certificate data. .. seealso:: https://cryptography.io/en/latest/faq/#why-can-t-i-import-my-pem-file @@ -39,6 +104,45 @@ def load_pem_x509_cert(pem_value: Union[str, bytes]) -> X509Cert: return x509_cert +def x509_cert_der_to_pem(der_value: bytes) -> bytes: + """ + Convert an X.509 certificate DER-encoded data to PEM-encoded. + + .. warning:: + It does not validate that ``der_value`` corresponds to an X.509 cert. + + :raises TypeError: + + """ + if not isinstance(der_value, bytes): + raise TypeError("Value must be bytes.") + + pem_value = base64.standard_b64encode(der_value) + mod_pem_value = add_pem_cert_header_footer(pem_value) + + return mod_pem_value.strip() + + +def x509_cert_pem_to_der(pem_value: bytes) -> bytes: + """ + Convert an X.509 certificate PEM-encoded data to DER-encoded. + + .. warning:: + It does not validate that ``pem_value`` corresponds to an X.509 cert. + + :raises TypeError: + :raises ValueError: + + """ + if not isinstance(pem_value, bytes): + raise TypeError("Value must be bytes.") + + mod_pem_value = remove_pem_cert_header_footer(pem_value) + der_value = encoding_utils.decode_base64_strict(mod_pem_value) + + return der_value.strip() + + def add_pem_cert_header_footer(pem_cert: bytes) -> bytes: """ Add certificate PEM header and footer (if not already present). diff --git a/cl_sii/libs/tz_utils.py b/cl_sii/libs/tz_utils.py index 8c6ec286..9d420f07 100644 --- a/cl_sii/libs/tz_utils.py +++ b/cl_sii/libs/tz_utils.py @@ -1,3 +1,15 @@ +""" +Timezone utils +============== + + +Naive and aware +--------------- + +These concept are defined in Python standard library module datetime +`docs `_. + +""" from datetime import datetime from typing import Union @@ -14,8 +26,12 @@ ] -UTC = pytz.UTC # type: PytzTimezone -TIMEZONE_CL_SANTIAGO = pytz.timezone('America/Santiago') # type: PytzTimezone +TZ_UTC = pytz.UTC # type: PytzTimezone +TZ_CL_SANTIAGO = pytz.timezone('America/Santiago') # type: PytzTimezone + +# TODO: remove +UTC = TZ_UTC +TIMEZONE_CL_SANTIAGO = TZ_CL_SANTIAGO def get_now_tz_aware() -> datetime: @@ -31,7 +47,7 @@ def get_now_tz_aware() -> datetime: # - `pytz.UTC.localize(datetime.utcnow())` # source: 'django.utils.timezone.now' @ Django 2.1.3 - return datetime.utcnow().replace(tzinfo=UTC) + return datetime.utcnow().replace(tzinfo=TZ_UTC) def convert_naive_dt_to_tz_aware(dt: datetime, tz: PytzTimezone) -> datetime: @@ -44,13 +60,13 @@ def convert_naive_dt_to_tz_aware(dt: datetime, tz: PytzTimezone) -> datetime: >>> dt_naive.isoformat() '2018-10-23T01:54:13' - >>> dt_tz_aware_1 = convert_naive_dt_to_tz_aware(dt_naive, UTC) + >>> dt_tz_aware_1 = convert_naive_dt_to_tz_aware(dt_naive, TZ_UTC) >>> dt_tz_aware_1 datetime.datetime(2018, 10, 23, 1, 54, 13, tzinfo=) >>> dt_tz_aware_1.isoformat() '2018-10-23T04:54:13+00:00' - >>> dt_tz_aware_2 = convert_naive_dt_to_tz_aware(dt_naive, TIMEZONE_CL_SANTIAGO) + >>> dt_tz_aware_2 = convert_naive_dt_to_tz_aware(dt_naive, TZ_CL_SANTIAGO) >>> dt_tz_aware_2 datetime.datetime(2018, 10, 23, 1, 54, 13, tzinfo=) @@ -64,3 +80,41 @@ def convert_naive_dt_to_tz_aware(dt: datetime, tz: PytzTimezone) -> datetime: """ dt_tz_aware = tz.localize(dt) # type: datetime return dt_tz_aware + + +def dt_is_aware(value: datetime) -> bool: + """ + Return whether datetime ``value`` is "aware". + + >>> dt_naive = datetime(2018, 10, 23, 1, 54, 13) + >>> dt_is_aware(dt_naive) + False + >>> dt_is_aware(convert_naive_dt_to_tz_aware(dt_naive, TZ_UTC)) + True + >>> dt_is_aware(convert_naive_dt_to_tz_aware(dt_naive, TZ_CL_SANTIAGO)) + True + + """ + if not isinstance(value, datetime): + raise TypeError + # source: 'django.utils.timezone.is_aware' @ Django 2.1.7 + return value.utcoffset() is not None + + +def dt_is_naive(value: datetime) -> bool: + """ + Return whether datetime ``value`` is "naive". + + >>> dt_naive = datetime(2018, 10, 23, 1, 54, 13) + >>> dt_is_naive(dt_naive) + True + >>> dt_is_naive(convert_naive_dt_to_tz_aware(dt_naive, TZ_UTC)) + False + >>> dt_is_naive(convert_naive_dt_to_tz_aware(dt_naive, TZ_CL_SANTIAGO)) + False + + """ + if not isinstance(value, datetime): + raise TypeError + # source: 'django.utils.timezone.is_naive' @ Django 2.1.7 + return value.utcoffset() is None diff --git a/cl_sii/rcv/parse.py b/cl_sii/rcv/parse.py index 0e5547d9..8f22786a 100644 --- a/cl_sii/rcv/parse.py +++ b/cl_sii/rcv/parse.py @@ -76,7 +76,7 @@ class _RcvCsvDialect(csv.Dialect): class RcvCsvRowSchema(marshmallow.Schema): EXPECTED_INPUT_FIELDS = tuple(_RCV_CSV_EXPECTED_FIELD_NAMES) + (_CSV_ROW_DICT_EXTRA_FIELDS_KEY, ) # type: ignore # noqa: E501 - FIELD_FECHA_RECEPCION_DATETIME_TZ = tz_utils.TIMEZONE_CL_SANTIAGO + FIELD_FECHA_RECEPCION_DATETIME_TZ = tz_utils.TZ_CL_SANTIAGO class Meta: strict = True diff --git a/docs/project-maintenance.rst b/docs/project-maintenance.rst index 59fba582..258de200 100644 --- a/docs/project-maintenance.rst +++ b/docs/project-maintenance.rst @@ -46,7 +46,7 @@ Add new entry to changelog including the changes summary (remember to format as Either of the following alternatives:: bumpversion major|minor|patch - bumpversion --new-version 'X.Y.Z' + bumpversion --new-version 'X.Y.Z' part # 'part' is a dummy argument. Push commit ``abcd1234`` and tag ``vX.Y.Z`` automatically created by ``bumpversion``:: diff --git a/tests/test_data/crypto/wildcard-google-com-cert.der b/tests/test_data/crypto/wildcard-google-com-cert.der new file mode 100644 index 00000000..0adf2dc5 Binary files /dev/null and b/tests/test_data/crypto/wildcard-google-com-cert.der differ diff --git a/tests/test_data/sii-crypto/DTE--76354771-K--33--170-cert.der b/tests/test_data/sii-crypto/DTE--76354771-K--33--170-cert.der new file mode 100644 index 00000000..10f8cc22 Binary files /dev/null and b/tests/test_data/sii-crypto/DTE--76354771-K--33--170-cert.der differ diff --git a/tests/test_data/sii-crypto/DTE--76399752-9--33--25568-cert.der b/tests/test_data/sii-crypto/DTE--76399752-9--33--25568-cert.der new file mode 100644 index 00000000..d0db0974 Binary files /dev/null and b/tests/test_data/sii-crypto/DTE--76399752-9--33--25568-cert.der differ diff --git a/tests/test_data/sii-crypto/prueba-sii-cert.der b/tests/test_data/sii-crypto/prueba-sii-cert.der new file mode 100644 index 00000000..fa5faf8b Binary files /dev/null and b/tests/test_data/sii-crypto/prueba-sii-cert.der differ diff --git a/tests/test_dte_data_models.py b/tests/test_dte_data_models.py index d0d4acad..d4e1d567 100644 --- a/tests/test_dte_data_models.py +++ b/tests/test_dte_data_models.py @@ -2,6 +2,8 @@ import unittest from datetime import date, datetime +from cl_sii.libs import encoding_utils +from cl_sii.libs import tz_utils from cl_sii.rut import Rut # noqa: F401 from cl_sii.dte.constants import TipoDteEnum # noqa: F401 @@ -10,6 +12,8 @@ validate_contribuyente_razon_social, validate_dte_folio, validate_dte_monto_total, ) +from .utils import read_test_file_bytes + class DteNaturalKeyTest(unittest.TestCase): @@ -137,6 +141,15 @@ def test_vendedor_rut_deudor_rut(self) -> None: class DteDataL2Test(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + super().setUpClass() + + cls.dte_1_xml_signature_value = encoding_utils.decode_base64_strict(read_test_file_bytes( + 'test_data/sii-crypto/DTE--76354771-K--33--170-signature-value-base64.txt')) + cls.dte_1_xml_cert_der = read_test_file_bytes( + 'test_data/sii-crypto/DTE--76354771-K--33--170-cert.der') + def setUp(self) -> None: super().setUp() @@ -150,9 +163,11 @@ def setUp(self) -> None: emisor_razon_social='INGENIERIA ENACON SPA', receptor_razon_social='MINERA LOS PELAMBRES', fecha_vencimiento_date=None, - firma_documento_dt_naive=datetime(2019, 4, 1, 1, 36, 40), - signature_value=None, - signature_x509_cert_pem=None, + firma_documento_dt=tz_utils.convert_naive_dt_to_tz_aware( + dt=datetime(2019, 4, 1, 1, 36, 40), + tz=DteDataL2.DATETIME_FIELDS_TZ), + signature_value=self.dte_1_xml_signature_value, + signature_x509_cert_der=self.dte_1_xml_cert_der, emisor_giro='Ingenieria y Construccion', emisor_email='hello@example.com', receptor_email=None, @@ -175,9 +190,11 @@ def test_as_dict(self) -> None: emisor_razon_social='INGENIERIA ENACON SPA', receptor_razon_social='MINERA LOS PELAMBRES', fecha_vencimiento_date=None, - firma_documento_dt_naive=datetime(2019, 4, 1, 1, 36, 40), - signature_value=None, - signature_x509_cert_pem=None, + firma_documento_dt=tz_utils.convert_naive_dt_to_tz_aware( + dt=datetime(2019, 4, 1, 1, 36, 40), + tz=DteDataL2.DATETIME_FIELDS_TZ), + signature_value=self.dte_1_xml_signature_value, + signature_x509_cert_der=self.dte_1_xml_cert_der, emisor_giro='Ingenieria y Construccion', emisor_email='hello@example.com', receptor_email=None, @@ -197,3 +214,23 @@ def test_validate_dte_folio(self) -> None: def test_validate_dte_monto_total(self) -> None: # TODO: implement for 'validate_dte_monto_total' pass + + def test_validate_clean_str(self) -> None: + # TODO: implement for 'validate_clean_str' + pass + + def test_validate_clean_bytes(self) -> None: + # TODO: implement for 'validate_clean_bytes' + pass + + def test_validate_non_empty_str(self) -> None: + # TODO: implement for 'validate_non_empty_str' + pass + + def test_validate_non_empty_bytes(self) -> None: + # TODO: implement for 'validate_non_empty_bytes' + pass + + def test_validate_correct_tz(self) -> None: + # TODO: implement for 'validate_correct_tz' + pass diff --git a/tests/test_dte_parse.py b/tests/test_dte_parse.py index 1944f17d..d8f4198e 100644 --- a/tests/test_dte_parse.py +++ b/tests/test_dte_parse.py @@ -4,8 +4,10 @@ from datetime import date, datetime import cl_sii.dte.constants +from cl_sii.dte.data_models import DteDataL2 from cl_sii.libs import crypto_utils from cl_sii.libs import encoding_utils +from cl_sii.libs import tz_utils from cl_sii.libs import xml_utils from cl_sii.rut import Rut @@ -293,9 +295,13 @@ def setUpClass(cls) -> None: cls.dte_clean_xml_1_cert_pem_bytes = encoding_utils.clean_base64( crypto_utils.remove_pem_cert_header_footer( read_test_file_bytes('test_data/sii-crypto/DTE--76354771-K--33--170-cert.pem'))) + cls.dte_clean_xml_1_cert_der = read_test_file_bytes( + 'test_data/sii-crypto/DTE--76354771-K--33--170-cert.der') cls.dte_clean_xml_2_cert_pem_bytes = encoding_utils.clean_base64( crypto_utils.remove_pem_cert_header_footer( read_test_file_bytes('test_data/sii-crypto/DTE--76399752-9--33--25568-cert.pem'))) + cls.dte_clean_xml_2_cert_der = read_test_file_bytes( + 'test_data/sii-crypto/DTE--76399752-9--33--25568-cert.der') cls._TEST_DTE_1_SIGNATURE_VALUE = encoding_utils.decode_base64_strict( read_test_file_bytes( @@ -325,6 +331,13 @@ def test_data(self): b"\xe5]E\xed\x9c\xcb\xc2\x84\x15i\xd0tT]\x8b\x8a\x1f'\xe9\x0b:\x88\x05|\xa0b\xb2" b"\x19{\x1cW\x80\xe4\xa7*\xef\xf2\x1a") + self.assertEqual( + crypto_utils.x509_cert_pem_to_der(self.dte_clean_xml_1_cert_pem_bytes), + self.dte_clean_xml_1_cert_der) + self.assertEqual( + crypto_utils.x509_cert_pem_to_der(self.dte_clean_xml_2_cert_pem_bytes), + self.dte_clean_xml_2_cert_der) + def test_parse_dte_xml_ok_1(self) -> None: xml_doc = xml_utils.parse_untrusted_xml(self.dte_clean_xml_1_xml_bytes) @@ -341,9 +354,11 @@ def test_parse_dte_xml_ok_1(self) -> None: emisor_razon_social='INGENIERIA ENACON SPA', receptor_razon_social='MINERA LOS PELAMBRES', fecha_vencimiento_date=None, - firma_documento_dt_naive=datetime(2019, 4, 1, 1, 36, 40), + firma_documento_dt=tz_utils.convert_naive_dt_to_tz_aware( + dt=datetime(2019, 4, 1, 1, 36, 40), + tz=DteDataL2.DATETIME_FIELDS_TZ), signature_value=self._TEST_DTE_1_SIGNATURE_VALUE, - signature_x509_cert_pem=self.dte_clean_xml_1_cert_pem_bytes, + signature_x509_cert_der=self.dte_clean_xml_1_cert_der, emisor_giro='Ingenieria y Construccion', emisor_email='ENACONLTDA@GMAIL.COM', receptor_email=None, @@ -365,9 +380,11 @@ def test_parse_dte_xml_ok_2(self) -> None: emisor_razon_social='COMERCIALIZADORA INNOVA MOBEL SPA', receptor_razon_social='EMPRESAS LA POLAR S.A.', fecha_vencimiento_date=None, - firma_documento_dt_naive=datetime(2019, 3, 28, 13, 59, 52), + firma_documento_dt=tz_utils.convert_naive_dt_to_tz_aware( + dt=datetime(2019, 3, 28, 13, 59, 52), + tz=DteDataL2.DATETIME_FIELDS_TZ), signature_value=self._TEST_DTE_2_SIGNATURE_VALUE, - signature_x509_cert_pem=self.dte_clean_xml_2_cert_pem_bytes, + signature_x509_cert_der=self.dte_clean_xml_2_cert_der, emisor_giro='COMERCIALIZACION DE PRODUCTOS PARA EL HOGAR', emisor_email='ANGEL.PEZO@APCASESORIAS.CL', receptor_email=None, diff --git a/tests/test_libs_crypto_utils.py b/tests/test_libs_crypto_utils.py index 4fe317f7..304e7b84 100644 --- a/tests/test_libs_crypto_utils.py +++ b/tests/test_libs_crypto_utils.py @@ -6,7 +6,9 @@ from cryptography.x509 import oid from cl_sii.libs.crypto_utils import ( # noqa: F401 - X509Cert, add_pem_cert_header_footer, load_pem_x509_cert, remove_pem_cert_header_footer, + X509Cert, add_pem_cert_header_footer, load_der_x509_cert, load_pem_x509_cert, + remove_pem_cert_header_footer, + x509_cert_der_to_pem, x509_cert_pem_to_der, ) from . import utils @@ -40,11 +42,11 @@ def test_remove_pem_cert_header_footer(self) -> None: class LoadPemX509CertTest(unittest.TestCase): - def test_load_pem_x509_cert_ok(self) -> None: - cert_pem_bytes = utils.read_test_file_bytes( - 'test_data/crypto/wildcard-google-com-cert.pem') + def test_load_der_x509_cert_ok(self) -> None: + cert_der_bytes = utils.read_test_file_bytes( + 'test_data/crypto/wildcard-google-com-cert.der') - x509_cert = load_pem_x509_cert(cert_pem_bytes) + x509_cert = load_der_x509_cert(cert_der_bytes) self.assertIsInstance(x509_cert, X509Cert) @@ -217,11 +219,11 @@ def test_load_pem_x509_cert_ok(self) -> None: self.assertIs(crl_distribution_points_ext.value._distribution_points[0].reasons, None) self.assertIs(crl_distribution_points_ext.value._distribution_points[0].relative_name, None) - def test_load_pem_x509_cert_ok_cert_real_dte(self) -> None: - cert_pem_bytes = utils.read_test_file_bytes( - 'test_data/sii-crypto/DTE--76354771-K--33--170-cert.pem') + def test_load_der_x509_cert_ok_cert_real_dte(self) -> None: + cert_der_bytes = utils.read_test_file_bytes( + 'test_data/sii-crypto/DTE--76354771-K--33--170-cert.der') - x509_cert = load_pem_x509_cert(cert_pem_bytes) + x509_cert = load_der_x509_cert(cert_der_bytes) self.assertIsInstance(x509_cert, X509Cert) @@ -439,10 +441,11 @@ def test_load_pem_x509_cert_ok_cert_real_dte(self) -> None: self.assertEqual(some_microsoft_ext.critical, False) self.assertTrue(isinstance(some_microsoft_ext.value.value, bytes)) - def test_load_pem_x509_cert_ok_prueba_sii(self) -> None: - cert_pem_bytes = utils.read_test_file_bytes('test_data/sii-crypto/prueba-sii-cert.pem') + def test_load_der_x509_cert_ok_prueba_sii(self) -> None: + cert_der_bytes = utils.read_test_file_bytes( + 'test_data/sii-crypto/prueba-sii-cert.der') - x509_cert = load_pem_x509_cert(cert_pem_bytes) + x509_cert = load_der_x509_cert(cert_der_bytes) self.assertIsInstance(x509_cert, X509Cert) @@ -615,6 +618,54 @@ def test_load_pem_x509_cert_ok_prueba_sii(self) -> None: self.assertIs(crl_distribution_points_ext.value._distribution_points[0].reasons, None) self.assertIs(crl_distribution_points_ext.value._distribution_points[0].relative_name, None) + def test_load_der_x509_cert_fail_type_error(self) -> None: + with self.assertRaises(TypeError) as cm: + load_der_x509_cert(1) + self.assertEqual(cm.exception.args, ("Value must be bytes.", )) + + def test_load_der_x509_cert_fail_value_error(self) -> None: + with self.assertRaises(ValueError) as cm: + load_der_x509_cert(b'hello') + self.assertEqual( + cm.exception.args, + ("Unable to load certificate", )) + + def test_load_pem_x509_cert_ok(self) -> None: + cert_der_bytes = utils.read_test_file_bytes( + 'test_data/crypto/wildcard-google-com-cert.der') + cert_pem_bytes = utils.read_test_file_bytes( + 'test_data/crypto/wildcard-google-com-cert.pem') + + x509_cert_from_der = load_der_x509_cert(cert_der_bytes) + x509_cert_from_pem = load_pem_x509_cert(cert_pem_bytes) + + self.assertIsInstance(x509_cert_from_pem, X509Cert) + self.assertEqual(x509_cert_from_der, x509_cert_from_pem) + + def test_load_pem_x509_cert_ok_cert_real_dte(self) -> None: + cert_der_bytes = utils.read_test_file_bytes( + 'test_data/sii-crypto/DTE--76354771-K--33--170-cert.der') + cert_pem_bytes = utils.read_test_file_bytes( + 'test_data/sii-crypto/DTE--76354771-K--33--170-cert.pem') + + x509_cert_from_der = load_der_x509_cert(cert_der_bytes) + x509_cert_from_pem = load_pem_x509_cert(cert_pem_bytes) + + self.assertIsInstance(x509_cert_from_pem, X509Cert) + self.assertEqual(x509_cert_from_der, x509_cert_from_pem) + + def test_load_pem_x509_cert_ok_prueba_sii(self) -> None: + cert_der_bytes = utils.read_test_file_bytes( + 'test_data/sii-crypto/prueba-sii-cert.der') + cert_pem_bytes = utils.read_test_file_bytes( + 'test_data/sii-crypto/prueba-sii-cert.pem') + + x509_cert_from_der = load_der_x509_cert(cert_der_bytes) + x509_cert_from_pem = load_pem_x509_cert(cert_pem_bytes) + + self.assertIsInstance(x509_cert_from_pem, X509Cert) + self.assertEqual(x509_cert_from_der, x509_cert_from_pem) + def test_load_pem_x509_cert_ok_str_ascii(self) -> None: cert_pem_str_ascii = utils.read_test_file_str_ascii( 'test_data/crypto/wildcard-google-com-cert.pem') @@ -642,3 +693,68 @@ def test_load_pem_x509_cert_fail_value_error(self) -> None: ("Unable to load certificate. See " "https://cryptography.io/en/latest/faq/#why-can-t-i-import-my-pem-file " "for more details.", )) + + def test_x509_cert_der_to_pem_pem_to_der_ok_1(self) -> None: + cert_der_bytes = utils.read_test_file_bytes( + 'test_data/crypto/wildcard-google-com-cert.der') + cert_pem_bytes = utils.read_test_file_bytes( + 'test_data/crypto/wildcard-google-com-cert.pem') + + # note: we test the function with a double call because the input PEM data + # may have different line lengths and different line separators. + self.assertEqual( + x509_cert_pem_to_der(x509_cert_der_to_pem(cert_der_bytes)), + x509_cert_pem_to_der(cert_pem_bytes)) + + def test_x509_cert_der_to_pem_pem_to_der_ok_2(self) -> None: + cert_der_bytes = utils.read_test_file_bytes( + 'test_data/sii-crypto/DTE--76354771-K--33--170-cert.der') + cert_pem_bytes = utils.read_test_file_bytes( + 'test_data/sii-crypto/DTE--76354771-K--33--170-cert.pem') + + # note: we test the function with a double call because the input PEM data + # may have different line lengths and different line separators. + self.assertEqual( + x509_cert_pem_to_der(x509_cert_der_to_pem(cert_der_bytes)), + x509_cert_pem_to_der(cert_pem_bytes)) + + def test_x509_cert_der_to_pem_pem_to_der_ok_3(self) -> None: + cert_der_bytes = utils.read_test_file_bytes( + 'test_data/sii-crypto/prueba-sii-cert.der') + cert_pem_bytes = utils.read_test_file_bytes( + 'test_data/sii-crypto/prueba-sii-cert.pem') + + # note: we test the function with a double call because the input PEM data + # may have different line lengths and different line separators. + self.assertEqual( + x509_cert_pem_to_der(x509_cert_der_to_pem(cert_der_bytes)), + x509_cert_pem_to_der(cert_pem_bytes)) + + def test_x509_cert_der_to_pem_type_error(self) -> None: + with self.assertRaises(TypeError) as cm: + x509_cert_der_to_pem(1) + self.assertEqual(cm.exception.args, ("Value must be bytes.", )) + + def test_x509_cert_pem_to_der_type_error(self) -> None: + with self.assertRaises(TypeError) as cm: + x509_cert_pem_to_der(1) + self.assertEqual(cm.exception.args, ("Value must be bytes.", )) + + def test_x509_cert_pem_to_der_valuetype_error(self) -> None: + with self.assertRaises(ValueError) as cm: + x509_cert_pem_to_der(b'hello') + self.assertEqual( + cm.exception.args, + ( + "Input is not a valid base64 value.", + "Invalid base64-encoded string: number of data characters (5) cannot be 1 more " + "than a multiple of 4", + )) + + def test_add_pem_cert_header_footer(self) -> None: + # TODO: implement for 'add_pem_cert_header_footer' + pass + + def test_remove_pem_cert_header_footer(self) -> None: + # TODO: implement for 'remove_pem_cert_header_footer' + pass diff --git a/tests/test_libs_tz_utils.py b/tests/test_libs_tz_utils.py index 8d2bcb32..4fe2145b 100644 --- a/tests/test_libs_tz_utils.py +++ b/tests/test_libs_tz_utils.py @@ -1,16 +1,29 @@ import unittest -from cl_sii.libs.tz_utils import convert_naive_dt_to_tz_aware, get_now_tz_aware # noqa: F401 +from cl_sii.libs.tz_utils import ( # noqa: F401 + convert_naive_dt_to_tz_aware, dt_is_aware, dt_is_naive, get_now_tz_aware, + PytzTimezone, TZ_CL_SANTIAGO, TZ_UTC, +) class FunctionsTest(unittest.TestCase): def test_get_now_tz_aware(self) -> None: - # TODO: implement! + # TODO: implement for 'get_now_tz_aware' # Reuse doctests/examples in function docstring. pass def test_convert_naive_dt_to_tz_aware(self) -> None: - # TODO: implement! + # TODO: implement for 'convert_naive_dt_to_tz_aware' + # Reuse doctests/examples in function docstring. + pass + + def test_dt_is_aware(self) -> None: + # TODO: implement for 'dt_is_aware' + # Reuse doctests/examples in function docstring. + pass + + def test_dt_is_naive(self) -> None: + # TODO: implement for 'dt_is_naive' # Reuse doctests/examples in function docstring. pass