From 40acfd147a42e299fdd88f467e8d1f1897e8060c Mon Sep 17 00:00:00 2001 From: Daniel Alley Date: Thu, 17 Sep 2026 22:59:06 -0400 Subject: [PATCH] Extend tests with PQC coverage Assisted-By: Codex 5.6 Luna --- .../tests/functional/api/test_crud_remotes.py | 16 ++++ pulp_deb/tests/functional/api/test_publish.py | 80 +++++++++++++++++++ pulp_deb/tests/functional/conftest.py | 76 +++++++++++++----- pulp_deb/tests/functional/constants.py | 11 +++ .../tests/unit/test_phase_out_reminders.py | 39 --------- pyproject.toml | 2 +- 6 files changed, 163 insertions(+), 61 deletions(-) delete mode 100644 pulp_deb/tests/unit/test_phase_out_reminders.py diff --git a/pulp_deb/tests/functional/api/test_crud_remotes.py b/pulp_deb/tests/functional/api/test_crud_remotes.py index 0290bf718..03957db35 100644 --- a/pulp_deb/tests/functional/api/test_crud_remotes.py +++ b/pulp_deb/tests/functional/api/test_crud_remotes.py @@ -4,13 +4,29 @@ from uuid import uuid4 import pytest +import requests from pulpcore.client.pulp_deb.exceptions import ApiException +from pulpcore.pytest_plugin import KEY_V6_ED25519_PUBLIC, KEY_V6_MLDSA65_ED25519_PUBLIC from pulp_deb.tests.functional.constants import DOWNLOAD_POLICIES from pulp_deb.tests.functional.utils import gen_deb_remote_verbose +@pytest.mark.parametrize("key_url", [KEY_V6_ED25519_PUBLIC, KEY_V6_MLDSA65_ED25519_PUBLIC]) +def test_create_remote_with_openpgp_gpgkey( + apt_remote_api, deb_remote_custom_data_factory, deb_get_fixture_server_url, key_url +): + """Verify that classical and PQC OpenPGP keys can be stored on an AptRemote.""" + key = requests.get(key_url) + key.raise_for_status() + remote = deb_remote_custom_data_factory( + gen_deb_remote_verbose(deb_get_fixture_server_url()) | {"gpgkey": key.text} + ) + + assert remote.gpgkey == key.text.rstrip() + + @pytest.fixture def deb_init_verbose_remote(deb_get_fixture_server_url, deb_remote_custom_data_factory): """A fixture that initializes are deb remote with verbose data.""" diff --git a/pulp_deb/tests/functional/api/test_publish.py b/pulp_deb/tests/functional/api/test_publish.py index d27cb3b66..22126f4f0 100644 --- a/pulp_deb/tests/functional/api/test_publish.py +++ b/pulp_deb/tests/functional/api/test_publish.py @@ -3,11 +3,23 @@ from random import choice import pytest +import requests from debian import deb822 from django.conf import settings from pulpcore.client.pulp_deb.exceptions import ApiException +from pulpcore.pytest_plugin import ( + KEY_V6_ED25519_PRIVATE, + KEY_V6_ED25519_PUBLIC, + KEY_V6_MLDSA65_ED25519_PRIVATE, + KEY_V6_MLDSA65_ED25519_PUBLIC, +) +from pulp_deb.tests.functional.conftest import ( + create_signing_service, + import_signing_key, + remove_signing_service, +) from pulp_deb.tests.functional.constants import ( DEB_FIXTURE_ALT_SINGLE_DIST, DEB_FIXTURE_ARCH, @@ -23,6 +35,7 @@ DEB_PACKAGE_INDEX_NAME, DEB_PACKAGE_NAME, DEB_PACKAGE_RELEASE_COMPONENT_NAME, + DEB_PQC_SIGNING_SCRIPT_STRING, DEB_PUBLICATION_ARGS_ALL, DEB_PUBLICATION_ARGS_NESTED_ALPHABETICALLY, DEB_PUBLICATION_ARGS_NESTED_BY_BOTH, @@ -270,7 +283,10 @@ def test_publish_layout( apt_distribution_api, create_publication_and_verify_repo_version, deb_distribution_factory, + deb_delete_publication, + deb_delete_repository, download_content_unit, + pulpcore_bindings, publication_args, ): """Test whether a the layout parameter is generating expected package URLs @@ -382,6 +398,70 @@ def test_publish_signing_services( deb_delete_repository(repo) +@pytest.mark.parametrize( + "private_key_url, public_key_url", + [ + (KEY_V6_ED25519_PRIVATE, KEY_V6_ED25519_PUBLIC), + (KEY_V6_MLDSA65_ED25519_PRIVATE, KEY_V6_MLDSA65_ED25519_PUBLIC), + ], + ids=["v6-ed25519", "v6-mldsa65-ed25519"], +) +def test_release_signing_service( + tmp_path, + create_publication_and_verify_repo_version, + deb_delete_publication, + deb_delete_repository, + deb_distribution_factory, + download_content_unit, + pulpcore_bindings, + private_key_url, + public_key_url, +): + """Verify API-created signing services publish detached and inline signatures.""" + backend = "sq" + home = tmp_path / backend + home.mkdir() + _, fingerprint, _ = import_signing_key(private_key_url, home, backend=backend) + script = tmp_path / "sign.sh" + script.write_text( + DEB_PQC_SIGNING_SCRIPT_STRING.replace("SQ_HOME", str(home)).replace( + "PQC_SIGNER", fingerprint + ) + ) + public_key = requests.get(public_key_url) + public_key.raise_for_status() + public_key = public_key.text + script.chmod(0o755) + service_name = create_signing_service( + home, + fingerprint, + script, + service_class="deb:AptReleaseSigningService", + backend=backend, + ) + try: + service = pulpcore_bindings.SigningServicesApi.list(name=service_name).results[0] + publication, repo, _, _ = create_publication_and_verify_repo_version( + {"distributions": DEB_FIXTURE_SINGLE_DIST}, + {"signing_service": service.pulp_href}, + ) + distribution = deb_distribution_factory(publication) + inrelease = download_content_unit( + distribution.to_dict()["base_path"], "dists/ragnarok/InRelease" + ) + assert inrelease.startswith(b"-----BEGIN PGP SIGNED MESSAGE-----") + release_gpg = download_content_unit( + distribution.to_dict()["base_path"], "dists/ragnarok/Release.gpg" + ) + from pysequoia import Sig + + Sig.from_bytes(release_gpg) + deb_delete_publication(publication) + deb_delete_repository(repo) + finally: + remove_signing_service(service_name, service_class="deb:AptReleaseSigningService") + + @pytest.mark.parallel def test_publish_repository_version_verbatim( create_publication_and_verify_repo_version, diff --git a/pulp_deb/tests/functional/conftest.py b/pulp_deb/tests/functional/conftest.py index 1422c27eb..aa7929284 100644 --- a/pulp_deb/tests/functional/conftest.py +++ b/pulp_deb/tests/functional/conftest.py @@ -539,40 +539,72 @@ def _deb_copy_content_domain( return _deb_copy_content_domain -def import_signing_key(key_url, gpg_home): - """Import a PGP key into a GPG home directory and trust it. +def import_signing_key(key_url, home, *, backend="gpg"): + """Import a PGP key into a keyring and return metadata. - Returns ``(gpg, fingerprint, keyid)``. + Returns `(gpg_instance_or_none, fingerprint, keyid)`. The first element + is a `gnupg.GPG` instance when `backend` is `"gpg"`, or `None` when + `backend` is `"sq"`. """ - try: - import gnupg - except ImportError: - pytest.skip("python-gnupg not installed") - - gpg = gnupg.GPG(gnupghome=gpg_home) - response = requests.get(key_url) response.raise_for_status() - result = gpg.import_keys(response.content) - assert result.count >= 1, f"Failed to import key from {key_url}" - key_info = gpg.list_keys()[0] - fingerprint = key_info["fingerprint"] - keyid = key_info["keyid"] - gpg.trust_keys(fingerprint, "TRUST_ULTIMATE") + if backend == "sq": + from pysequoia import Cert - return gpg, fingerprint, keyid + def openpgp_key_id(fingerprint): + """Return the OpenPGP key ID for a hexadecimal fingerprint. + + OpenPGP v4 key IDs use the low-order 64 bits, while v6 key IDs use the + high-order 64 bits. The fingerprint length distinguishes these versions. + """ + return (fingerprint[:16] if len(fingerprint) == 64 else fingerprint[-16:]).upper() + + completed = subprocess.run( + ("sq", "--home", str(home), "key", "import"), + input=response.content, + capture_output=True, + ) + assert completed.returncode == 0, completed.stderr.decode() + + cert = Cert.from_bytes(response.content) + fingerprint = cert.fingerprint.upper() + keyid = openpgp_key_id(fingerprint) + + return None, fingerprint, keyid + else: + try: + import gnupg + except ImportError: + pytest.skip("python-gnupg not installed") + + gpg = gnupg.GPG(gnupghome=home) + + result = gpg.import_keys(response.content) + assert result.count >= 1, f"Failed to import key from {key_url}" + + key_info = gpg.list_keys()[0] + fingerprint = key_info["fingerprint"] + keyid = key_info["keyid"] + gpg.trust_keys(fingerprint, "TRUST_ULTIMATE") + + return gpg, fingerprint, keyid def create_signing_service( - gpg_home, fingerprint, script_path, *, service_class="core:AsciiArmoredDetachedSigningService" + gpg_home, + fingerprint, + script_path, + *, + service_class="core:AsciiArmoredDetachedSigningService", + backend="gpg", ): """Register a signing service via pulpcore-manager. Returns the service name. """ service_name = str(uuid.uuid4()) - cmd = ( + cmd = [ "pulpcore-manager", "add-signing-service", service_name, @@ -580,9 +612,11 @@ def create_signing_service( fingerprint, "--class", service_class, - "--gnupghome", + "--backend", + backend, + "--home", str(gpg_home), - ) + ] completed = subprocess.run(cmd, capture_output=True, text=True) assert completed.returncode == 0, completed.stderr diff --git a/pulp_deb/tests/functional/constants.py b/pulp_deb/tests/functional/constants.py index 5fe530f69..39fcc735a 100644 --- a/pulp_deb/tests/functional/constants.py +++ b/pulp_deb/tests/functional/constants.py @@ -471,6 +471,17 @@ def _clean_dict(d): } """ +DEB_PQC_SIGNING_SCRIPT_STRING = r"""#!/bin/sh + +sq --home "SQ_HOME" sign --signer "PQC_SIGNER" \ + --signature-file="$PULP_TEMP_WORKING_DIR/Release.gpg" "$1" +sq --home "SQ_HOME" sign --signer "PQC_SIGNER" --cleartext \ + --output="$PULP_TEMP_WORKING_DIR/InRelease" "$1" +python3 -c 'import json, os; print(json.dumps({"signatures": { +"detached": os.environ["PULP_TEMP_WORKING_DIR"] + "/Release.gpg", +"inline": os.environ["PULP_TEMP_WORKING_DIR"] + "/InRelease"}}))' +""" + DEB_PACKAGE_SIGNING_SCRIPT_STRING = r"""#!/usr/bin/env bash export GNUPGHOME="HOMEDIRHERE" GPG_NAME="${PULP_SIGNING_KEY_FINGERPRINT}" diff --git a/pulp_deb/tests/unit/test_phase_out_reminders.py b/pulp_deb/tests/unit/test_phase_out_reminders.py deleted file mode 100644 index b9f8451f1..000000000 --- a/pulp_deb/tests/unit/test_phase_out_reminders.py +++ /dev/null @@ -1,39 +0,0 @@ -"""Scheduled reminders disguised as tests — these exist to nag, not to verify behavior.""" - -from importlib.metadata import requires as pkg_requires - -from packaging.requirements import Requirement -from packaging.version import Version - - -def _pulpcore_lower_bound(): - deps = pkg_requires("pulp-deb") or [] - pulpcore_spec = next(d for d in deps if d.split(";")[0].strip().startswith("pulpcore")) - pulpcore_spec = pulpcore_spec.split(";")[0].strip() - req = Requirement(pulpcore_spec) - lower_bounds = [spec.version for spec in req.specifier if spec.operator in (">=", ">")] - if not lower_bounds: - raise ValueError(f"Could not find a lower bound in: {pulpcore_spec!r}") - return Version(max(lower_bounds, key=Version)) - - -def test_pulpcore_lower_bound_below_3_115(): - """ - This is a reminder mechanism, not a functional test. - - Once the pulpcore lower bound in pyproject.toml reaches 3.115, the - distribution/publication phase-out work tracked in - https://github.com/pulp/pulp_deb/issues/1430 should be finished and this - test should be removed. - - If this test is failing, poke Pedro (@pedro-brochado on Matrix) to complete - the phase-out and delete this test. - """ - lower_bound = _pulpcore_lower_bound() - assert lower_bound < Version("3.115"), ( - f"pulpcore lower bound is {lower_bound}, which is >= 3.115. " - "This is a reminder that the distribution/publication phase-out " - "(https://github.com/pulp/pulp_deb/issues/1430) should now be " - "completed. Poke @pedro-brochado on Matrix to finish the phase-out " - "and remove this test." - ) diff --git a/pyproject.toml b/pyproject.toml index d889546b2..de289544a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ requires-python = ">=3.11" dependencies = [ # All things django and asyncio are deliberately left to pulpcore # Example transitive requirements: asgiref, asyncio, aiohttp - "pulpcore>=3.113.0,<3.130", + "pulpcore>=3.117.2,<3.130", "python-debian>=0.1.44,<0.2.0", "jsonschema>=4.6,<5.0", ]