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