Skip to content

Commit 1b0dde0

Browse files
committed
move tests to submodules and pytest
1 parent afe81ac commit 1b0dde0

File tree

9 files changed

+1124
-1118
lines changed

9 files changed

+1124
-1118
lines changed

ca/django_ca/tests/base/assertions.py

Lines changed: 41 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
from cryptography.x509.oid import ExtensionOID
3131
from OpenSSL.crypto import FILETYPE_PEM, X509Store, X509StoreContext, load_certificate
3232

33-
from django.core.exceptions import ImproperlyConfigured
33+
from django.core.exceptions import ImproperlyConfigured, ValidationError
3434
from django.core.management import CommandError
3535

3636
import pytest
@@ -40,7 +40,7 @@
4040
from django_ca.deprecation import RemovedInDjangoCA220Warning
4141
from django_ca.key_backends.storages import UsePrivateKeyOptions
4242
from django_ca.models import Certificate, CertificateAuthority, X509CertMixin
43-
from django_ca.signals import post_create_ca, post_issue_cert, pre_create_ca, pre_sign_cert
43+
from django_ca.signals import post_create_ca, post_issue_cert, post_sign_cert, pre_create_ca, pre_sign_cert
4444
from django_ca.tests.base.mocks import mock_signal
4545
from django_ca.tests.base.utils import (
4646
authority_information_access,
@@ -122,23 +122,27 @@ def assert_ca_properties(
122122

123123

124124
def assert_certificate(
125-
cert: Union[Certificate, CertificateAuthority],
125+
cert: Union[x509.Certificate, CertificateAuthority, Certificate],
126126
subject: x509.Name,
127127
algorithm: type[hashes.HashAlgorithm] = hashes.SHA512,
128-
parent: Optional[CertificateAuthority] = None,
128+
signer: Optional[Union[CertificateAuthority, x509.Certificate]] = None,
129129
) -> None:
130130
"""Assert certificate properties."""
131-
if isinstance(cert, Certificate): # pragma: no cover # pylint: disable=no-else-raise
132-
parent = cert.ca
133-
raise NotImplementedError("Remove no-cover pragma if this is caught.")
134-
elif parent is None:
135-
parent = cert
136-
else:
137-
parent = cert.parent
138-
assert cert.pub.loaded.version == x509.Version.v3
139-
assert cert.issuer == parent.subject # type: ignore[union-attr]
131+
if isinstance(cert, CertificateAuthority):
132+
if cert.parent is not None:
133+
signer = cert.parent
134+
cert = cert.pub.loaded
135+
elif isinstance(cert, Certificate): # pragma: no cover # not used, currently
136+
signer = cert.ca
137+
cert = cert.pub.loaded
138+
139+
if signer is None:
140+
signer = cert
141+
142+
assert cert.version == x509.Version.v3
143+
assert cert.issuer == signer.subject
140144
assert cert.subject == subject
141-
assert isinstance(cert.algorithm, algorithm)
145+
assert isinstance(cert.signature_hash_algorithm, algorithm)
142146

143147

144148
@contextmanager
@@ -177,7 +181,7 @@ def assert_create_cert_signals(pre: bool = True, post: bool = True) -> Iterator[
177181

178182

179183
def assert_crl( # noqa: PLR0913
180-
crl: bytes,
184+
crl: Union[bytes, x509.CertificateRevocationList],
181185
expected: Optional[typing.Sequence[X509CertMixin]] = None,
182186
signer: Optional[CertificateAuthority] = None,
183187
expires: int = 86400,
@@ -221,7 +225,9 @@ def assert_crl( # noqa: PLR0913
221225
)
222226
)
223227

224-
if encoding == Encoding.PEM:
228+
if isinstance(crl, x509.CertificateRevocationList):
229+
parsed_crl = crl
230+
elif encoding == Encoding.PEM:
225231
parsed_crl = x509.load_pem_x509_crl(crl)
226232
else:
227233
parsed_crl = x509.load_der_x509_crl(crl)
@@ -386,6 +392,17 @@ def assert_revoked(
386392
assert cert.revoked_reason == reason
387393

388394

395+
@contextmanager
396+
def assert_sign_cert_signals(pre: bool = True, post: bool = True) -> Iterator[tuple[Mock, Mock]]:
397+
"""Context manager mocking both ``pre_create_ca`` and ``post_create_ca`` signals."""
398+
with mock_signal(pre_sign_cert) as pre_sig, mock_signal(post_sign_cert) as post_sig:
399+
try:
400+
yield pre_sig, post_sig
401+
finally:
402+
assert pre_sig.called is pre
403+
assert post_sig.called is post
404+
405+
389406
def assert_signature(
390407
chain: Iterable[CertificateAuthority], cert: Union[Certificate, CertificateAuthority]
391408
) -> None:
@@ -425,3 +442,11 @@ def assert_removed_in_220(match: Optional[Union[str, "re.Pattern[str]"]] = None)
425442
"""Assert that a ``RemovedInDjangoCA200Warning`` is emitted."""
426443
with pytest.warns(RemovedInDjangoCA220Warning, match=match):
427444
yield
445+
446+
447+
@contextmanager
448+
def assert_validation_error(errors: dict[str, list[str]]) -> Iterator[None]:
449+
"""Context manager to assert that a ValidationError is thrown."""
450+
with pytest.raises(ValidationError) as excinfo:
451+
yield
452+
assert excinfo.value.message_dict == errors

ca/django_ca/tests/base/mixins.py

Lines changed: 2 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -31,18 +31,16 @@
3131
from django.contrib.auth.models import User # pylint: disable=imported-auth-user; for mypy
3232
from django.contrib.messages import get_messages
3333
from django.core.cache import cache
34-
from django.core.exceptions import ValidationError
3534
from django.test.testcases import SimpleTestCase
3635
from django.urls import reverse
3736

3837
from freezegun import freeze_time
3938
from freezegun.api import FrozenDateTimeFactory, StepTickTimeFactory
4039

4140
from django_ca.models import Certificate, CertificateAuthority, DjangoCAModel, X509CertMixin
42-
from django_ca.signals import post_revoke_cert, post_sign_cert, pre_sign_cert
41+
from django_ca.signals import post_revoke_cert
4342
from django_ca.tests.admin.assertions import assert_change_response, assert_changelist_response
4443
from django_ca.tests.base.constants import CERT_DATA
45-
from django_ca.tests.base.mocks import mock_signal
4644
from django_ca.tests.base.typehints import DjangoCAModelTypeVar
4745

4846
if typing.TYPE_CHECKING:
@@ -154,18 +152,6 @@ def absolute_uri(self, name: str, hostname: Optional[str] = None, **kwargs: Any)
154152
name = f"django_ca{name}"
155153
return f"http://{hostname}{reverse(name, kwargs=kwargs)}"
156154

157-
@contextmanager
158-
def assertSignCertSignals( # pylint: disable=invalid-name
159-
self, pre: bool = True, post: bool = True
160-
) -> Iterator[tuple[mock.Mock, mock.Mock]]:
161-
"""Context manager mocking both pre and post_create_ca signals."""
162-
with mock_signal(pre_sign_cert) as pre_sig, mock_signal(post_sign_cert) as post_sig:
163-
try:
164-
yield pre_sig, post_sig
165-
finally:
166-
self.assertTrue(pre_sig.called is pre)
167-
self.assertTrue(post_sig.called is post)
168-
169155
def assertAuthorityInformationAccessEqual( # pylint: disable=invalid-name
170156
self,
171157
first: x509.AuthorityInformationAccess,
@@ -260,15 +246,6 @@ def assertPostRevoke(self, post: mock.Mock, cert: Certificate) -> None: # pylin
260246
"""Assert that the post_revoke_cert signal was called."""
261247
post.assert_called_once_with(cert=cert, signal=post_revoke_cert, sender=Certificate)
262248

263-
@contextmanager
264-
def assertValidationError( # pylint: disable=invalid-name; unittest standard
265-
self, errors: dict[str, list[str]]
266-
) -> Iterator[None]:
267-
"""Context manager to assert that a ValidationError is thrown."""
268-
with self.assertRaises(ValidationError) as cmex:
269-
yield
270-
self.assertEqual(cmex.exception.message_dict, errors)
271-
272249
def crl_distribution_points(
273250
self,
274251
full_name: Optional[Iterable[x509.GeneralName]] = None,
@@ -433,7 +410,7 @@ def patch_object(self, *args: Any, **kwargs: Any) -> Iterator[Any]:
433410
def usable_cas(self) -> Iterator[tuple[str, CertificateAuthority]]:
434411
"""Yield loaded generated certificates."""
435412
for name, ca in self.cas.items():
436-
if CERT_DATA[name]["key_filename"]:
413+
if CERT_DATA[name]["key_filename"]: # pragma: no branch
437414
yield name, ca
438415

439416

ca/django_ca/tests/base/utils.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -506,7 +506,7 @@ def subject_key_identifier(
506506
cert: Union[X509CertMixin, x509.Certificate],
507507
) -> x509.Extension[x509.SubjectKeyIdentifier]:
508508
"""Shortcut for getting a SubjectKeyIdentifier extension."""
509-
if isinstance(cert, X509CertMixin):
509+
if isinstance(cert, X509CertMixin): # pragma: no branch - usually full certificate is passed.
510510
cert = cert.pub.loaded
511511

512512
ski = x509.SubjectKeyIdentifier.from_public_key(cert.public_key())

ca/django_ca/tests/models/__init__.py

Whitespace-only changes.

ca/django_ca/tests/models/base.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# This file is part of django-ca (https://github.com/mathiasertl/django-ca).
2+
#
3+
# django-ca is free software: you can redistribute it and/or modify it under the terms of the GNU General
4+
# Public License as published by the Free Software Foundation, either version 3 of the License, or (at your
5+
# option) any later version.
6+
#
7+
# django-ca is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the
8+
# implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
9+
# for more details.
10+
#
11+
# You should have received a copy of the GNU General Public License along with django-ca. If not, see
12+
# <http://www.gnu.org/licenses/>.
13+
14+
"""Common functions for testing models."""
15+
16+
from django_ca.models import X509CertMixin
17+
from django_ca.tests.base.constants import CERT_PEM_REGEX
18+
19+
20+
def assert_bundle(chain: list[X509CertMixin], cert: X509CertMixin) -> None:
21+
"""Assert that a bundle contains the expected certificates."""
22+
encoded_chain = [c.pub.pem.encode() for c in chain]
23+
24+
# Make sure that all PEMs end with a newline. RFC 7468 does not mandate a newline at the end, but it
25+
# seems in practice we always get one. We want to detect if that ever changes
26+
for member in encoded_chain:
27+
assert member.endswith(b"\n")
28+
29+
bundle = cert.bundle_as_pem
30+
assert isinstance(bundle, str)
31+
assert bundle.endswith("\n")
32+
33+
# Test the regex used by certbot to make sure certbot finds the expected certificates
34+
found = CERT_PEM_REGEX.findall(bundle.encode())
35+
assert encoded_chain == found
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
# This file is part of django-ca (https://github.com/mathiasertl/django-ca).
2+
#
3+
# django-ca is free software: you can redistribute it and/or modify it under the terms of the GNU General
4+
# Public License as published by the Free Software Foundation, either version 3 of the License, or (at your
5+
# option) any later version.
6+
#
7+
# django-ca is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the
8+
# implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
9+
# for more details.
10+
#
11+
# You should have received a copy of the GNU General Public License along with django-ca. If not, see
12+
# <http://www.gnu.org/licenses/>.
13+
14+
"""Test the Certificate model."""
15+
16+
from datetime import datetime, timedelta
17+
18+
import josepy as jose
19+
20+
from cryptography import x509
21+
from cryptography.hazmat.primitives import hashes
22+
23+
from django.utils import timezone
24+
25+
import pytest
26+
from _pytest.logging import LogCaptureFixture
27+
from pytest_django.fixtures import SettingsWrapper
28+
29+
from django_ca.constants import ReasonFlags
30+
from django_ca.models import Certificate, CertificateAuthority
31+
from django_ca.tests.base.assertions import assert_validation_error
32+
from django_ca.tests.base.constants import CERT_DATA
33+
from django_ca.tests.models.base import assert_bundle
34+
35+
36+
def test_bundle_as_pem(
37+
root: CertificateAuthority, root_cert: Certificate, child: CertificateAuthority, child_cert: Certificate
38+
) -> None:
39+
"""Test bundles of various CAs."""
40+
assert_bundle([root_cert, root], root_cert)
41+
assert_bundle([child_cert, child, root], child_cert)
42+
43+
44+
def test_revocation() -> None:
45+
"""Test getting a revociation for a non-revoked certificate."""
46+
# Never really happens in real life, but should still be checked
47+
cert = Certificate(revoked=False)
48+
49+
with pytest.raises(ValueError):
50+
cert.get_revocation()
51+
52+
53+
def test_root(root: CertificateAuthority, root_cert: Certificate, child_cert: Certificate) -> None:
54+
"""Test the root property."""
55+
assert root_cert.root == root
56+
assert child_cert.root == root
57+
58+
59+
def test_serial(usable_cert: Certificate) -> None:
60+
"""Test getting the serial."""
61+
cert_name = usable_cert.test_name # type: ignore[attr-defined]
62+
assert usable_cert.serial == CERT_DATA[cert_name].get("serial")
63+
64+
65+
@pytest.mark.freeze_time("2019-02-03 15:43:12")
66+
def test_get_revocation_time(settings: SettingsWrapper, root_cert: Certificate) -> None:
67+
"""Test getting the revocation time."""
68+
assert root_cert.get_revocation_time() is None
69+
root_cert.revoke()
70+
71+
# timestamp does not have a timezone regardless of USE_TZ
72+
root_cert.revoked_date = timezone.now()
73+
assert root_cert.get_revocation_time() == datetime(2019, 2, 3, 15, 43, 12)
74+
75+
settings.USE_TZ = False
76+
root_cert.refresh_from_db()
77+
assert root_cert.get_revocation_time() == datetime(2019, 2, 3, 15, 43, 12)
78+
79+
80+
@pytest.mark.freeze_time("2019-02-03 15:43:12")
81+
def test_get_compromised_time(settings: SettingsWrapper, root_cert: Certificate) -> None:
82+
"""Test getting the time when the certificate was compromised."""
83+
assert root_cert.get_compromised_time() is None
84+
root_cert.revoke(compromised=timezone.now())
85+
86+
# timestamp does not have a timezone regardless of USE_TZ
87+
root_cert.compromised = timezone.now()
88+
assert root_cert.get_compromised_time() == datetime(2019, 2, 3, 15, 43, 12)
89+
90+
settings.USE_TZ = False
91+
root_cert.refresh_from_db()
92+
assert root_cert.compromised == timezone.now()
93+
assert root_cert.get_compromised_time() == datetime(2019, 2, 3, 15, 43, 12)
94+
95+
96+
def test_get_revocation_reason(root_cert: Certificate) -> None:
97+
"""Test getting the revocation reason."""
98+
assert root_cert.get_revocation_reason() is None
99+
100+
for reason in ReasonFlags:
101+
root_cert.revoke(reason)
102+
got = root_cert.get_revocation_reason()
103+
assert isinstance(got, x509.ReasonFlags)
104+
assert got.name == reason.name
105+
106+
107+
def test_validate_past(root_cert: Certificate) -> None:
108+
"""Test that model validation blocks revoked_date or revoked_invalidity in the future."""
109+
now = timezone.now()
110+
future = now + timedelta(10)
111+
past = now - timedelta(10)
112+
113+
# Validation works if we're not revoked
114+
root_cert.full_clean()
115+
116+
# Validation works if date is in the past
117+
root_cert.revoked_date = past
118+
root_cert.compromised = past
119+
root_cert.full_clean()
120+
121+
root_cert.revoked_date = future
122+
root_cert.compromised = future
123+
with assert_validation_error(
124+
{
125+
"compromised": ["Date must be in the past!"],
126+
"revoked_date": ["Date must be in the past!"],
127+
}
128+
):
129+
root_cert.full_clean()
130+
131+
132+
@pytest.mark.parametrize("name,algorithm", (("sha256", hashes.SHA256()), ("sha512", hashes.SHA512())))
133+
def test_get_fingerprint(name: str, algorithm: hashes.HashAlgorithm, usable_cert: Certificate) -> None:
134+
"""Test getting the fingerprint value."""
135+
cert_name = usable_cert.test_name # type: ignore[attr-defined]
136+
assert usable_cert.get_fingerprint(algorithm) == CERT_DATA[cert_name][name]
137+
138+
139+
def test_jwk(root_cert: Certificate, ec_cert: Certificate) -> None:
140+
"""Test JWK property."""
141+
# josepy does not support loading DSA/Ed448/Ed25519 keys:
142+
# https://github.com/certbot/josepy/pull/98
143+
assert isinstance(ec_cert.jwk, jose.jwk.JWKEC)
144+
assert isinstance(root_cert.jwk, jose.jwk.JWKRSA)
145+
146+
147+
def test_jwk_with_unsupported_algorithm(
148+
dsa_cert: Certificate, ed448_cert: Certificate, ed25519_cert: Certificate
149+
) -> None:
150+
"""Test the ValueError raised if called with an unsupported algorithm."""
151+
with pytest.raises(ValueError, match="Unsupported algorithm"):
152+
ed448_cert.jwk # noqa: B018
153+
with pytest.raises(ValueError, match="Unsupported algorithm"):
154+
ed25519_cert.jwk # noqa: B018
155+
with pytest.raises(ValueError, match="Unsupported algorithm"):
156+
dsa_cert.jwk # noqa: B018
157+
158+
159+
def test_revocation_with_no_revocation_date(root_cert: Certificate) -> None:
160+
"""Test exception when no revocation date is set."""
161+
root_cert.revoked = True
162+
root_cert.save()
163+
164+
with pytest.raises(ValueError, match=r"^Certificate has no revocation date$"):
165+
root_cert.get_revocation()
166+
167+
168+
def test_get_revocation_time_with_no_revocation_date(
169+
caplog: LogCaptureFixture, root_cert: Certificate
170+
) -> None:
171+
"""Test warning log message when there is no revocation time set but the cert is revoked."""
172+
root_cert.revoked = True
173+
root_cert.save()
174+
175+
assert root_cert.get_revocation_time() is None
176+
assert "Inconsistent model state: revoked=True and revoked_date=None." in caplog.text

0 commit comments

Comments
 (0)