From 0365e7d01f5c2f817eeb9af89606cb853e46b512 Mon Sep 17 00:00:00 2001 From: Mathias Ertl Date: Sat, 19 Oct 2024 12:06:15 +0200 Subject: [PATCH] also extensively test migrations --- .../migrations/0003_auto_20170304_1434.py | 31 ++-- .../migrations/0010_auto_20181128_2054.py | 3 +- .../migrations/0014_auto_20190518_1046.py | 17 +- .../migrations/0016_auto_20190706_1548.py | 25 ++- .../migrations/0023_auto_20210429_0000.py | 10 +- .../migrations/0038_auto_20231228_1932.py | 2 +- .../migrations/0040_auto_20240120_0931.py | 7 +- .../migrations/0043_auto_20240221_2153.py | 20 ++- .../migrations/0048_auto_20241017_2104.py | 48 ++++-- ca/django_ca/migrations/pyproject.toml | 17 ++ ca/django_ca/tests/migrations/__init__.py | 0 ca/django_ca/tests/migrations/test_0040.py | 108 ++++++++++++ ca/django_ca/tests/migrations/test_0043.py | 91 ++++++++++ ca/django_ca/tests/migrations/test_0048.py | 156 ++++++++++++++++++ pyproject.toml | 2 +- requirements/requirements-dev-common.txt | 1 + 16 files changed, 461 insertions(+), 77 deletions(-) create mode 100644 ca/django_ca/migrations/pyproject.toml create mode 100644 ca/django_ca/tests/migrations/__init__.py create mode 100644 ca/django_ca/tests/migrations/test_0040.py create mode 100644 ca/django_ca/tests/migrations/test_0043.py create mode 100644 ca/django_ca/tests/migrations/test_0048.py diff --git a/ca/django_ca/migrations/0003_auto_20170304_1434.py b/ca/django_ca/migrations/0003_auto_20170304_1434.py index 61886c10c..887875865 100644 --- a/ca/django_ca/migrations/0003_auto_20170304_1434.py +++ b/ca/django_ca/migrations/0003_auto_20170304_1434.py @@ -3,31 +3,30 @@ from django.db import migrations -def migrate_revocation_reasons(apps, schema_editor): - Certificate = apps.get_model('django_ca', 'Certificate') +def migrate_revocation_reasons(apps, schema_editor): # pragma: no cover + Certificate = apps.get_model("django_ca", "Certificate") certs = Certificate.objects.exclude(revoked_reason__isnull=True) - for cert in certs.exclude(revoked_reason__in=['', 'unspecified', 'superseded']): - if cert.revoked_reason == 'keyCompromise': - cert.revoked_reason = 'key_compromise' - elif cert.revoked_reason == 'caCompromise': - cert.revoked_reason = 'ca_compromise' - elif cert.revoked_reason == 'affiliationChanged': - cert.revoked_reason = 'affiliation_changed' - elif cert.revoked_reason == 'cessationOfOperation': - cert.revoked_reason = 'cessation_of_operation' - elif cert.revoked_reason == 'certificateHold': - cert.revoked_reason = 'certificate_hold' + for cert in certs.exclude(revoked_reason__in=["", "unspecified", "superseded"]): + if cert.revoked_reason == "keyCompromise": + cert.revoked_reason = "key_compromise" + elif cert.revoked_reason == "caCompromise": + cert.revoked_reason = "ca_compromise" + elif cert.revoked_reason == "affiliationChanged": + cert.revoked_reason = "affiliation_changed" + elif cert.revoked_reason == "cessationOfOperation": + cert.revoked_reason = "cessation_of_operation" + elif cert.revoked_reason == "certificateHold": + cert.revoked_reason = "certificate_hold" else: - raise RuntimeError('Unknown revocation reason encountered: %s' % cert.revoked_reason) + raise RuntimeError("Unknown revocation reason encountered: %s" % cert.revoked_reason) cert.save() class Migration(migrations.Migration): - dependencies = [ - ('django_ca', '0002_auto_20170304_1434'), + ("django_ca", "0002_auto_20170304_1434"), ] operations = [ diff --git a/ca/django_ca/migrations/0010_auto_20181128_2054.py b/ca/django_ca/migrations/0010_auto_20181128_2054.py index 7d204f27a..e4b5e894c 100644 --- a/ca/django_ca/migrations/0010_auto_20181128_2054.py +++ b/ca/django_ca/migrations/0010_auto_20181128_2054.py @@ -7,7 +7,7 @@ from django.utils import timezone -def add_valid_from(apps, schema_editor): +def add_valid_from(apps, schema_editor): # pragma: no cover Certificate = apps.get_model("django_ca", "Certificate") for cert in Certificate.objects.all(): pem = x509.load_pem_x509_certificate(cert.pub.encode("ascii")) @@ -32,7 +32,6 @@ def add_valid_from(apps, schema_editor): class Migration(migrations.Migration): - dependencies = [ ("django_ca", "0009_auto_20181128_2050"), ] diff --git a/ca/django_ca/migrations/0014_auto_20190518_1046.py b/ca/django_ca/migrations/0014_auto_20190518_1046.py index 46ebd9d3b..71e8f968f 100644 --- a/ca/django_ca/migrations/0014_auto_20190518_1046.py +++ b/ca/django_ca/migrations/0014_auto_20190518_1046.py @@ -4,22 +4,17 @@ def remove_empty(apps, schema_editor): - Certificate = apps.get_model('django_ca', 'Certificate') - Certificate.objects.filter(revoked_reason='').update(revoked_reason='unspecified') - CertificateAuthority = apps.get_model('django_ca', 'CertificateAuthority') - CertificateAuthority.objects.filter(revoked_reason='').update(revoked_reason='unspecified') - - -def noop(): - pass + Certificate = apps.get_model("django_ca", "Certificate") + Certificate.objects.filter(revoked_reason="").update(revoked_reason="unspecified") + CertificateAuthority = apps.get_model("django_ca", "CertificateAuthority") + CertificateAuthority.objects.filter(revoked_reason="").update(revoked_reason="unspecified") class Migration(migrations.Migration): - dependencies = [ - ('django_ca', '0013_certificateauthority_crl_number'), + ("django_ca", "0013_certificateauthority_crl_number"), ] operations = [ - migrations.RunPython(remove_empty, noop), + migrations.RunPython(remove_empty, migrations.RunPython.noop), ] diff --git a/ca/django_ca/migrations/0016_auto_20190706_1548.py b/ca/django_ca/migrations/0016_auto_20190706_1548.py index 5848c958e..fa84aeb63 100644 --- a/ca/django_ca/migrations/0016_auto_20190706_1548.py +++ b/ca/django_ca/migrations/0016_auto_20190706_1548.py @@ -3,23 +3,23 @@ from django.db import migrations -def rm_colons(apps, schema_editor): - Certificate = apps.get_model('django_ca', 'Certificate') - CertificateAuthority = apps.get_model('django_ca', 'CertificateAuthority') +def rm_colons(apps, schema_editor): # pragma: no cover + Certificate = apps.get_model("django_ca", "Certificate") + CertificateAuthority = apps.get_model("django_ca", "CertificateAuthority") for ca in CertificateAuthority.objects.all(): - ca.serial = ca.serial.replace(':', '') + ca.serial = ca.serial.replace(":", "") ca.save() for cert in Certificate.objects.all(): - cert.serial = cert.serial.replace(':', '') + cert.serial = cert.serial.replace(":", "") cert.save() -def add_colons(apps, schema_editor): - Certificate = apps.get_model('django_ca', 'Certificate') - CertificateAuthority = apps.get_model('django_ca', 'CertificateAuthority') +def add_colons(apps, schema_editor): # pragma: no cover + Certificate = apps.get_model("django_ca", "Certificate") + CertificateAuthority = apps.get_model("django_ca", "CertificateAuthority") - add_c = lambda s: ':'.join([s[i:i + 2] for i in range(0, len(s), 2)]) # NOQA + add_c = lambda s: ":".join([s[i : i + 2] for i in range(0, len(s), 2)]) # NOQA for ca in CertificateAuthority.objects.all(): ca.serial = add_c(ca.serial) @@ -30,11 +30,8 @@ def add_colons(apps, schema_editor): class Migration(migrations.Migration): - dependencies = [ - ('django_ca', '0015_auto_20190518_1050'), + ("django_ca", "0015_auto_20190518_1050"), ] - operations = [ - migrations.RunPython(rm_colons, add_colons) - ] + operations = [migrations.RunPython(rm_colons, add_colons)] diff --git a/ca/django_ca/migrations/0023_auto_20210429_0000.py b/ca/django_ca/migrations/0023_auto_20210429_0000.py index e500439f0..4dcb555d4 100644 --- a/ca/django_ca/migrations/0023_auto_20210429_0000.py +++ b/ca/django_ca/migrations/0023_auto_20210429_0000.py @@ -5,7 +5,7 @@ from django.db import migrations -def migrate(apps, schema_editor): +def migrate(apps, schema_editor): # pragma: no cover Certificate = apps.get_model("django_ca", "Certificate") CertificateAuthority = apps.get_model("django_ca", "CertificateAuthority") @@ -34,17 +34,11 @@ def migrate(apps, schema_editor): ca.save() -def noop(apps, schema_editor): - """no need to do anything in backwards data migration.""" - pass - - class Migration(migrations.Migration): - dependencies = [ ("django_ca", "0022_auto_20210430_1124"), ] operations = [ - migrations.RunPython(migrate, noop), + migrations.RunPython(migrate, migrations.RunPython.noop), ] diff --git a/ca/django_ca/migrations/0038_auto_20231228_1932.py b/ca/django_ca/migrations/0038_auto_20231228_1932.py index dd4fd25b7..dfabf38da 100644 --- a/ca/django_ca/migrations/0038_auto_20231228_1932.py +++ b/ca/django_ca/migrations/0038_auto_20231228_1932.py @@ -3,7 +3,7 @@ from django.db import migrations -def update_sign_certificates_schema(apps, schema_editor) -> None: +def update_sign_certificates_schema(apps, schema_editor) -> None: # pragma: no cover """Migrate stored data to new Pydantic-based serialization.""" CertificateAuthority = apps.get_model("django_ca", "CertificateAuthority") for ca in CertificateAuthority.objects.exclude(sign_certificate_policies=None): diff --git a/ca/django_ca/migrations/0040_auto_20240120_0931.py b/ca/django_ca/migrations/0040_auto_20240120_0931.py index 36551ef73..d866d62ac 100644 --- a/ca/django_ca/migrations/0040_auto_20240120_0931.py +++ b/ca/django_ca/migrations/0040_auto_20240120_0931.py @@ -22,7 +22,6 @@ * issuer_url and ocsp_url -> sign_authority_information_access """ - import typing from django.db import migrations @@ -54,11 +53,11 @@ def reverse_extension_fields(apps: "StateApps", schema_editor: "BaseDatabaseSche ca.save() -class Migration(migrations.Migration): # noqa: D101 - dependencies = [ # noqa: RUF012 +class Migration(migrations.Migration): + dependencies = [ ("django_ca", "0039_certificateauthority_sign_authority_information_access_and_more"), ] - operations = [ # noqa: RUF012 + operations = [ migrations.RunPython(populate_extension_fields, reverse_extension_fields), ] diff --git a/ca/django_ca/migrations/0043_auto_20240221_2153.py b/ca/django_ca/migrations/0043_auto_20240221_2153.py index a13b637d9..225de945c 100644 --- a/ca/django_ca/migrations/0043_auto_20240221_2153.py +++ b/ca/django_ca/migrations/0043_auto_20240221_2153.py @@ -1,21 +1,37 @@ +# This file is part of django-ca (https://github.com/mathiasertl/django-ca). +# +# django-ca is free software: you can redistribute it and/or modify it under the terms of the GNU General +# Public License as published by the Free Software Foundation, either version 3 of the License, or (at your +# option) any later version. +# +# django-ca is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the +# implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +# for more details. +# +# You should have received a copy of the GNU General Public License along with django-ca. If not, see +# . +# # Generated by Django 5.0.2 on 2024-02-21 20:53 from django.db import migrations def migrate_path(apps, schema_editor): + """Forward migration.""" CertificateAuthority = apps.get_model("django_ca", "CertificateAuthority") for ca in CertificateAuthority.objects.all(): + ca.key_backend_alias = "default" ca.key_backend_options = {"path": ca.private_key_path} ca.save() def reverse_path(apps, schema_editor): + """Backward migration.""" CertificateAuthority = apps.get_model("django_ca", "CertificateAuthority") for ca in CertificateAuthority.objects.all(): - if ca.key_backend_path == "default" and "path" in ca.key_backend_options: + if ca.key_backend_alias == "default" and "path" in ca.key_backend_options: ca.private_key_path = ca.key_backend_options["path"] - else: + else: # pragma: no cover # django-test-migrations does not properly roll back in this case. raise ValueError(f"{ca.name}: CA does not use StoragesBackend, cannot revert this migration.") ca.save() diff --git a/ca/django_ca/migrations/0048_auto_20241017_2104.py b/ca/django_ca/migrations/0048_auto_20241017_2104.py index 1bd39aa41..5264a5f5d 100644 --- a/ca/django_ca/migrations/0048_auto_20241017_2104.py +++ b/ca/django_ca/migrations/0048_auto_20241017_2104.py @@ -1,9 +1,24 @@ +# This file is part of django-ca (https://github.com/mathiasertl/django-ca). +# +# django-ca is free software: you can redistribute it and/or modify it under the terms of the GNU General +# Public License as published by the Free Software Foundation, either version 3 of the License, or (at your +# option) any later version. +# +# django-ca is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the +# implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +# for more details. +# +# You should have received a copy of the GNU General Public License along with django-ca. If not, see +# . +# # Generated by Django 5.1.2 on 2024-10-17 19:04 + +"""Migration for moving CertificateAuthority.crl_number to a new CertificateRevocationList instance.""" + import json from datetime import timedelta from cryptography import x509 -from cryptography.hazmat.primitives.serialization import Encoding from django.conf import settings from django.core.cache import cache @@ -11,13 +26,13 @@ from django.utils import timezone from django.utils.timezone import make_naive -from django_ca.utils import get_crl_cache_key - def migrate_crl_number(apps, schema_editor): + """Forward operation: CertificateAuthority.crl_number -> CertificateRevocationList.""" CertificateAuthority = apps.get_model("django_ca", "CertificateAuthority") CertificateRevocationList = apps.get_model("django_ca", "CertificateRevocationList") + # Only migrate enabled certificate authorities for ca in CertificateAuthority.objects.filter(enabled=True): crl_number_data = json.loads(ca.crl_number) for scope, crl_number in crl_number_data.get("scope", {}).items(): @@ -31,24 +46,19 @@ def migrate_crl_number(apps, schema_editor): elif scope == "attribute": only_contains_attribute_certs = True - cache_key = get_crl_cache_key( - ca.serial, - Encoding.DER, - only_contains_ca_certs=only_contains_ca_certs, - only_contains_user_certs=only_contains_user_certs, - only_contains_attribute_certs=only_contains_attribute_certs, - only_some_reasons=None, # not supported in old format - ) + # This is how cache keys where computed before 2.1.0: + cache_key = f"crl_{ca.serial}_DER_{scope}" # Retrieve data from cache, if possible. try: crl_data = cache.get(cache_key) + crl = x509.load_der_x509_crl(crl_data) except Exception: crl_data = None + crl = None # If CRL was in the cache, set data, next_update and last_update. - if crl_data is not None: - crl = x509.load_der_x509_crl(crl_data) + if crl is not None: next_update = crl.next_update_utc last_update = crl.last_update_utc @@ -62,15 +72,18 @@ def migrate_crl_number(apps, schema_editor): next_update = timezone.now() - timedelta(days=1) # Create CRL object - CertificateRevocationList.objects.create( + CertificateRevocationList.objects.filter(only_some_reasons__isnull=True).get_or_create( ca=ca, number=crl_number, - last_update=last_update, - next_update=next_update, only_contains_ca_certs=only_contains_ca_certs, only_contains_user_certs=only_contains_user_certs, only_contains_attribute_certs=only_contains_attribute_certs, - data=crl_data, + defaults={ + "only_some_reasons": None, + "last_update": last_update, + "next_update": next_update, + "data": crl_data, + }, ) @@ -78,5 +91,4 @@ class Migration(migrations.Migration): dependencies = [ ("django_ca", "0047_certificaterevocationlist"), ] - operations = [migrations.RunPython(migrate_crl_number, migrations.RunPython.noop)] diff --git a/ca/django_ca/migrations/pyproject.toml b/ca/django_ca/migrations/pyproject.toml new file mode 100644 index 000000000..2de535a48 --- /dev/null +++ b/ca/django_ca/migrations/pyproject.toml @@ -0,0 +1,17 @@ +[tool.ruff] +extend = "../../../pyproject.toml" + +[tool.ruff.lint] +extend-ignore = [ + # D100: Missing docstring in public module - examples don't need docs + # auto-generated migration modules don't need a docstring. + "D100", + + # D101 Missing docstring in public class + # default migration classes look like this. + "D101", + + # RUF012 Mutable class attributes should be annotated with `typing.ClassVar` + # default migration classes work like this. + "RUF012", +] \ No newline at end of file diff --git a/ca/django_ca/tests/migrations/__init__.py b/ca/django_ca/tests/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/ca/django_ca/tests/migrations/test_0040.py b/ca/django_ca/tests/migrations/test_0040.py new file mode 100644 index 000000000..5739e29a8 --- /dev/null +++ b/ca/django_ca/tests/migrations/test_0040.py @@ -0,0 +1,108 @@ +# This file is part of django-ca (https://github.com/mathiasertl/django-ca). +# +# django-ca is free software: you can redistribute it and/or modify it under the terms of the GNU General +# Public License as published by the Free Software Foundation, either version 3 of the License, or (at your +# option) any later version. +# +# django-ca is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the +# implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +# for more details. +# +# You should have received a copy of the GNU General Public License along with django-ca. If not, see +# . +# +# pylint: disable=redefined-outer-name # because of fixtures +# pylint: disable=invalid-name # for model loading + +"""Test 0043 database migration.""" + +from typing import Callable, Optional + +from django_test_migrations.migrator import Migrator + +from django.db.migrations.state import ProjectState +from django.utils import timezone + +from django_ca.tests.base.constants import CERT_DATA +from django_ca.tests.base.utils import ( + authority_information_access, + crl_distribution_points, + distribution_point, + issuer_alternative_name, + uri, +) + + +def setup(migrator: Migrator, setup: Optional[Callable[[ProjectState], None]] = None) -> ProjectState: + """Set up a CA with a CRL Number for the given scope.""" + old_state = migrator.apply_initial_migration( + ("django_ca", "0039_certificateauthority_sign_authority_information_access_and_more") + ) + now = timezone.now() + + CertificateAuthority = old_state.apps.get_model("django_ca", "CertificateAuthority") + CertificateAuthority.objects.create( + name="foo", + pub=CERT_DATA["root"]["pub"]["parsed"], + cn="", + serial="123", + valid_from=now, # doesn't matter here, just need something with right tz. + expires=now, # doesn't matter here, just need something with right tz. + private_key_path="/foo/bar/ca.key", + crl_url="https://crl.example.com", + issuer_alt_name="https://ian.example.com", + ocsp_url="http://ocsp.example.com", + issuer_url="http://ocsp.example.com", + ) + + if setup is not None: + setup(old_state) + + return migrator.apply_tested_migration(("django_ca", "0040_auto_20240120_0931")) + + +def test_forward(migrator: Migrator) -> None: + """Test standard migration and backwards migration.""" + state = setup(migrator) + + CertificateAuthority = state.apps.get_model("django_ca", "CertificateAuthority") + ca = CertificateAuthority.objects.get(serial="123") + assert ca.sign_crl_distribution_points == crl_distribution_points( + distribution_point([uri("https://crl.example.com")]) + ) + assert ca.sign_issuer_alternative_name == issuer_alternative_name(uri("https://ian.example.com")) + assert ca.sign_authority_information_access == authority_information_access( + ca_issuers=[uri("http://ocsp.example.com")], ocsp=[uri("http://ocsp.example.com")] + ) + + +def test_backward(migrator: Migrator) -> None: + """Run migration backwards.""" + state = migrator.apply_tested_migration(("django_ca", "0040_auto_20240120_0931")) + + now = timezone.now() + CertificateAuthority = state.apps.get_model("django_ca", "CertificateAuthority") + CertificateAuthority.objects.create( + serial="123", + pub=CERT_DATA["root"]["pub"]["parsed"], + valid_from=now, # doesn't matter here, just need something with right tz. + expires=now, # doesn't matter here, just need something with right tz. + sign_crl_distribution_points=crl_distribution_points( + distribution_point([uri("https://crl.example.com")]) + ), + sign_issuer_alternative_name=issuer_alternative_name(uri("https://ian.example.com")), + sign_authority_information_access=authority_information_access( + ca_issuers=[uri("http://ocsp.example.com")], ocsp=[uri("http://ocsp.example.com")] + ), + ) + + new_state = migrator.apply_tested_migration( + ("django_ca", "0039_certificateauthority_sign_authority_information_access_and_more") + ) + + CertificateAuthority = new_state.apps.get_model("django_ca", "CertificateAuthority") + ca = CertificateAuthority.objects.get(serial="123") + assert ca.crl_url == "https://crl.example.com" + assert ca.issuer_alt_name == "URI:https://ian.example.com" + assert ca.issuer_url == "http://ocsp.example.com" + assert ca.ocsp_url == "http://ocsp.example.com" diff --git a/ca/django_ca/tests/migrations/test_0043.py b/ca/django_ca/tests/migrations/test_0043.py new file mode 100644 index 000000000..152c63c2c --- /dev/null +++ b/ca/django_ca/tests/migrations/test_0043.py @@ -0,0 +1,91 @@ +# This file is part of django-ca (https://github.com/mathiasertl/django-ca). +# +# django-ca is free software: you can redistribute it and/or modify it under the terms of the GNU General +# Public License as published by the Free Software Foundation, either version 3 of the License, or (at your +# option) any later version. +# +# django-ca is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the +# implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +# for more details. +# +# You should have received a copy of the GNU General Public License along with django-ca. If not, see +# . +# +# pylint: disable=redefined-outer-name # because of fixtures +# pylint: disable=invalid-name # for model loading + +"""Test 0043 database migration.""" + +from typing import Callable, Optional + +from django_test_migrations.migrator import Migrator + +from cryptography import x509 + +from django.db.migrations.state import ProjectState +from django.utils import timezone + +import pytest + +from django_ca.tests.base.constants import CERT_DATA + + +def setup(migrator: Migrator, setup: Optional[Callable[[ProjectState], None]] = None) -> ProjectState: + """Set up a CA with a CRL Number for the given scope.""" + old_state = migrator.apply_initial_migration( + ("django_ca", "0042_certificateauthority_key_backend_options_and_more") + ) + now = timezone.now() + + cert: x509.Certificate = CERT_DATA["root"]["pub"]["parsed"] + CertificateAuthority = old_state.apps.get_model("django_ca", "CertificateAuthority") + CertificateAuthority.objects.create( + name="foo", + pub=cert, + cn="", + serial="123", + valid_from=now, # doesn't matter here, just need something with right tz. + expires=now, # doesn't matter here, just need something with right tz. + private_key_path="/foo/bar/ca.key", + ) + + if setup is not None: + setup(old_state) + + return migrator.apply_tested_migration(("django_ca", "0043_auto_20240221_2153")) + + +def test_forward(migrator: Migrator) -> None: + """Test standard migration and backwards migration.""" + state = setup(migrator) + CertificateAuthority = state.apps.get_model("django_ca", "CertificateAuthority") + ca = CertificateAuthority.objects.get(serial="123") + assert ca.key_backend_alias == "default" + assert ca.key_backend_options == {"path": "/foo/bar/ca.key"} + + +def test_backward(migrator: Migrator) -> None: + """Apply migration backwards.""" + old_state = migrator.apply_initial_migration(("django_ca", "0043_auto_20240221_2153")) + + now = timezone.now() + cert: x509.Certificate = CERT_DATA["root"]["pub"]["parsed"] + CertificateAuthority = old_state.apps.get_model("django_ca", "CertificateAuthority") + CertificateAuthority.objects.create( + name="foo", + pub=cert, + cn="", + serial="123", + valid_from=now, # doesn't matter here, just need something with right tz. + expires=now, # doesn't matter here, just need something with right tz. + key_backend_alias="default", + key_backend_options={"path": "/foo/bar/ca.key"}, + ) + + state = migrator.apply_tested_migration( + ("django_ca", "0042_certificateauthority_key_backend_options_and_more") + ) + + CertificateAuthority = state.apps.get_model("django_ca", "CertificateAuthority") + ca = CertificateAuthority.objects.get(serial="123") + assert ca.private_key_path == "/foo/bar/ca.key" diff --git a/ca/django_ca/tests/migrations/test_0048.py b/ca/django_ca/tests/migrations/test_0048.py new file mode 100644 index 000000000..4b990eb53 --- /dev/null +++ b/ca/django_ca/tests/migrations/test_0048.py @@ -0,0 +1,156 @@ +# This file is part of django-ca (https://github.com/mathiasertl/django-ca). +# +# django-ca is free software: you can redistribute it and/or modify it under the terms of the GNU General +# Public License as published by the Free Software Foundation, either version 3 of the License, or (at your +# option) any later version. +# +# django-ca is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the +# implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +# for more details. +# +# You should have received a copy of the GNU General Public License along with django-ca. If not, see +# . + +# pylint: disable=redefined-outer-name # because of fixtures +# pylint: disable=invalid-name # for model loading + +"""Test 0048 database migration.""" + +import json +from typing import Callable, Optional +from unittest import mock + +from django_test_migrations.migrator import Migrator + +from cryptography import x509 + +from django.core.cache import cache +from django.db.migrations.state import ProjectState +from django.utils import timezone +from django.utils.timezone import make_naive + +import pytest +from pytest_django.fixtures import SettingsWrapper + +from django_ca.tests.base import constants +from django_ca.tests.base.constants import CERT_DATA + +# Fixture tries to query the cache, so always clear the cache +pytestmark = [pytest.mark.usefixtures("clear_cache")] + + +def setup( + migrator: Migrator, scope: str, setup: Optional[Callable[[ProjectState], None]] = None +) -> ProjectState: + """Set up a CA with a CRL Number for the given scope.""" + old_state = migrator.apply_initial_migration(("django_ca", "0047_certificaterevocationlist")) + now = timezone.now() + + cert: x509.Certificate = CERT_DATA["root"]["pub"]["parsed"] + CertificateAuthority = old_state.apps.get_model("django_ca", "CertificateAuthority") + CertificateAuthority.objects.create( + pub=cert, + cn="", + serial="123", + not_before=now, # doesn't matter here, just need something with right tz. + not_after=now, # doesn't matter here, just need something with right tz. + crl_number=json.dumps({"scope": {scope: 3}}), + ) + + if setup is not None: + setup(old_state) + + return migrator.apply_tested_migration(("django_ca", "0048_auto_20241017_2104")) + + +@pytest.mark.parametrize( + "scope,ca,user,attribute", + ( + ("all", False, False, False), + ("ca", True, False, False), + ("user", False, True, False), + ("attribute", False, False, True), + ), +) +def test_with_empty_cache(migrator: Migrator, scope: str, ca: bool, user: bool, attribute: bool) -> None: + """Test running the migration with an empty cache.""" + state = setup(migrator, scope) + CertificateRevocationList = state.apps.get_model("django_ca", "CertificateRevocationList") + crl = CertificateRevocationList.objects.get(ca__serial="123") + assert crl.data is None + assert crl.number == 3 + assert crl.only_contains_ca_certs == ca + assert crl.only_contains_user_certs == user + assert crl.only_contains_attribute_certs == attribute + assert crl.only_some_reasons is None + + +def test_with_cache(migrator: Migrator) -> None: + """Test running fixture with a populated cache.""" + with open(constants.FIXTURES_DIR / "root.ca.crl", "rb") as stream: + crl_data = stream.read() + x509_crl = x509.load_der_x509_crl(crl_data) + + # Use cache key as it was used before 2.1.0 + state = setup(migrator, "ca", lambda apps: cache.set("crl_123_DER_ca", crl_data)) + + CertificateRevocationList = state.apps.get_model("django_ca", "CertificateRevocationList") + crl = CertificateRevocationList.objects.get(ca__serial="123") + assert crl.data == crl_data # data was retrieved from the cache + assert crl.number == 3 + assert crl.only_contains_ca_certs is True + assert crl.only_contains_user_certs is False + assert crl.only_contains_attribute_certs is False + assert crl.only_some_reasons is None + assert crl.last_update == x509_crl.last_update_utc + assert crl.next_update == x509_crl.next_update_utc + + +def test_with_cache_with_use_tz_is_false(migrator: Migrator, settings: SettingsWrapper) -> None: + """Test running fixture with a populated cache, with USE_TZ=False.""" + settings.USE_TZ = False + with open(constants.FIXTURES_DIR / "root.ca.crl", "rb") as stream: + crl_data = stream.read() + x509_crl = x509.load_der_x509_crl(crl_data) + + # Use cache key as it was used before 2.1.0 + state = setup(migrator, "ca", lambda apps: cache.set("crl_123_DER_ca", crl_data)) + + CertificateRevocationList = state.apps.get_model("django_ca", "CertificateRevocationList") + crl = CertificateRevocationList.objects.get(ca__serial="123") + assert crl.data == crl_data # data was retrieved from the cache + assert crl.number == 3 + assert crl.only_contains_ca_certs is True + assert crl.only_contains_user_certs is False + assert crl.only_contains_attribute_certs is False + assert crl.only_some_reasons is None + assert crl.last_update == make_naive(x509_crl.last_update_utc) + assert crl.next_update == make_naive(x509_crl.next_update_utc) + + +def test_with_cache_with_exception(migrator: Migrator) -> None: + """Test migration when fetching from the cache throws an exception.""" + with mock.patch.object(cache, "get", autospec=True, side_effect=Exception()): + state = setup(migrator, "ca") + CertificateRevocationList = state.apps.get_model("django_ca", "CertificateRevocationList") + crl = CertificateRevocationList.objects.get(ca__serial="123") + assert crl.data is None + assert crl.number == 3 + assert crl.only_contains_ca_certs is True + assert crl.only_contains_user_certs is False + assert crl.only_contains_attribute_certs is False + assert crl.only_some_reasons is None + + +def test_with_cache_with_corrupted_data(migrator: Migrator) -> None: + """Test migration when fetching from the cache returns corrupted data.""" + with mock.patch.object(cache, "get", autospec=True, return_value=b"123"): + state = setup(migrator, "ca") + CertificateRevocationList = state.apps.get_model("django_ca", "CertificateRevocationList") + crl = CertificateRevocationList.objects.get(ca__serial="123") + assert crl.data is None + assert crl.number == 3 + assert crl.only_contains_ca_certs is True + assert crl.only_contains_user_certs is False + assert crl.only_contains_attribute_certs is False + assert crl.only_some_reasons is None diff --git a/pyproject.toml b/pyproject.toml index 1eda0562f..bf6fc0472 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -154,7 +154,7 @@ source = [ ] branch = true omit = [ - "*/migrations/*", + #"*/migrations/*", "*/tests/tests*", "*/tests/**/test_*", "ca/django_ca/mypy.py", diff --git a/requirements/requirements-dev-common.txt b/requirements/requirements-dev-common.txt index 102652cde..657526ced 100644 --- a/requirements/requirements-dev-common.txt +++ b/requirements/requirements-dev-common.txt @@ -4,6 +4,7 @@ Jinja2==3.1.4 PyYAML==6.0.2 Sphinx==7.2.6 coverage[toml]==7.6.1 +django-test-migrations==1.4.0 pytest==8.3.3 pytest-cov==5.0.0 pytest-django==4.9.0