"""Additional route-level tests: CSRs, tokens, import/export and templates.""" from __future__ import annotations import base64 import unittest from datetime import datetime, timedelta, timezone from tests.helpers.api import api_client from tests.helpers.asynctest import async_test from tests.helpers.oidc import oidc_user def _subject(cn: str) -> list[dict]: return [{"name": "C", "value": "DE"}, {"name": "O", "value": "KCA"}, {"name": "CN", "value": cn}] def _extensions(ca: bool = False) -> dict: return { "basic_constraints": {"critical": True, "ca": ca, "path_length": None}, "key_usage": {"critical": True, "usages": ["key_cert_sign", "crl_sign"] if ca else ["digital_signature"]}, "subject_key_identifier": {"critical": False}, "authority_key_identifier": {"critical": False}, } async def _vault(client) -> str: response = await client.post("/api/vaults", json={"name": "Test"}) assert response.status_code == 201, response.text return response.json()["id"] async def _key(client, vault_id: str, name: str = "key") -> dict: response = await client.post( f"/api/vaults/{vault_id}/keys", json={"name": name, "key_type": "rsa", "size": 2048} ) assert response.status_code == 201, response.text return response.json() async def _cert(client, vault_id: str, key_id: str, name: str, ca: bool = False, signer_id: str | None = None) -> dict: now = datetime.now(timezone.utc) response = await client.post( f"/api/vaults/{vault_id}/certificates", json={ "name": name, "key_id": key_id, "signer_id": signer_id, "subject": _subject(name), "not_before": now.isoformat(), "not_after": (now + timedelta(days=365)).isoformat(), "extensions": _extensions(ca), }, ) assert response.status_code == 201, response.text return response.json() class RouteExtraTests(unittest.TestCase): @async_test async def test_csr_creation_and_signing(self) -> None: async with api_client() as client: with oidc_user("alice"): vault_id = await _vault(client) ca_key = await _key(client, vault_id, "ca key") ca = await _cert(client, vault_id, ca_key["id"], "Root CA", ca=True) response = await client.post( f"/api/vaults/{vault_id}/requests", json={ "name": "web request", "subject": _subject("web.example.com"), "new_key": {"key_type": "ec", "curve": "secp256r1", "name": "web key"}, "extensions": _extensions(False), }, ) assert response.status_code == 201, response.text request = response.json() assert request["signature_valid"] is True response = await client.post( f"/api/vaults/{vault_id}/certificates", json={ "name": "web cert", "request_id": request["id"], "signer_id": ca["id"], "not_before": datetime.now(timezone.utc).isoformat(), "not_after": (datetime.now(timezone.utc) + timedelta(days=30)).isoformat(), }, ) assert response.status_code == 201, response.text cert = response.json() assert cert["issuer_id"] == ca["id"] response = await client.get(f"/api/vaults/{vault_id}/requests/{request['id']}") assert response.json()["signed"] is True @async_test async def test_token_authentication(self) -> None: async with api_client() as client: with oidc_user("alice"): response = await client.post("/api/tokens", json={"name": "script"}) assert response.status_code == 201, response.text secret = response.json()["token"] assert secret.startswith("kca_") # Session is no longer used: the bearer token authenticates. response = await client.get("/api/vaults", headers={"authorization": f"Bearer {secret}"}) assert response.status_code == 200 response = await client.post("/api/tokens", headers={"authorization": f"Bearer {secret}"}, json={"name": "x"}) assert response.status_code == 401 @async_test async def test_import_preview_and_commit(self) -> None: async with api_client() as client: with oidc_user("alice"): vault_id = await _vault(client) key = await _key(client, vault_id, "import key") cert = await _cert(client, vault_id, key["id"], "Import Me", ca=True) export = await client.post( f"/api/vaults/{vault_id}/certificates/{cert['id']}/export", json={"format": "pem"} ) pem = base64.b64decode(export.json()["content_base64"]) response = await client.post( f"/api/vaults/{vault_id}/import", json={"filename": "test.crt", "content_base64": base64.b64encode(pem).decode(), "dry_run": True}, ) assert response.status_code == 200, response.text assert response.json()["items"][0]["duplicate"] is True response = await client.post( f"/api/vaults/{vault_id}/import", json={"filename": "test.crt", "content_base64": base64.b64encode(pem).decode()}, ) assert response.status_code == 200 assert len(response.json()["skipped"]) == 1 @async_test async def test_settings_and_templates(self) -> None: async with api_client() as client: with oidc_user("alice"): vault_id = await _vault(client) response = await client.get(f"/api/vaults/{vault_id}/settings") assert response.status_code == 200 version = response.json()["version"] response = await client.patch( f"/api/vaults/{vault_id}/settings", json={"settings": {"default_hash": "sha384", "serial_len_bits": 80}, "version": version}, ) assert response.status_code == 200, response.text assert response.json()["settings"]["default_hash"] == "sha384" response = await client.get(f"/api/vaults/{vault_id}/templates") templates = response.json()["results"] assert any(t["id"] == "builtin:ca" for t in templates) response = await client.post( f"/api/vaults/{vault_id}/templates/builtin:ca/apply", json={"target": {"subject": [], "extensions": {}}}, ) assert response.status_code == 200 assert response.json()["extensions"]["basic_constraints"]["ca"] is True response = await client.post( f"/api/vaults/{vault_id}/templates/builtin:tls-server/apply", json={ "target": { "subject": [{"name": "CN", "value": "keep.example.com"}], "extensions": {}, }, "apply_subject": False, "apply_extensions": True, }, ) assert response.status_code == 200 merged = response.json() assert merged["subject"] == [{"name": "CN", "value": "keep.example.com"}] assert "serverAuth" in merged["extensions"]["extended_key_usage"]["usages"] settings_response = await client.get(f"/api/vaults/{vault_id}/settings") response = await client.patch( f"/api/vaults/{vault_id}/settings", json={ "settings": {"explicit_dn": ["CN", "not-a-real-attribute"]}, "version": settings_response.json()["version"], }, ) assert response.status_code == 400 @async_test async def test_revocation_and_crl(self) -> None: async with api_client() as client: with oidc_user("alice"): vault_id = await _vault(client) ca_key = await _key(client, vault_id, "ca") ca = await _cert(client, vault_id, ca_key["id"], "Root", ca=True) leaf_key = await _key(client, vault_id, "leaf") leaf = await _cert( client, vault_id, leaf_key["id"], "Leaf", signer_id=ca["id"] ) response = await client.post( f"/api/vaults/{vault_id}/certificates/{leaf['id']}/revoke", json={"reason": "keyCompromise"}, ) assert response.status_code == 200, response.text response = await client.get( f"/api/vaults/{vault_id}/certificates/{leaf['id']}" ) assert response.json()["revoked"] is True response = await client.post( f"/api/vaults/{vault_id}/certificates/{ca['id']}/crl", json={"days": 30}, ) assert response.status_code == 201, response.text crl = response.json() assert crl["revoked_count"] == 1 assert crl["crl_number"] == 1 @async_test async def test_key_delete_unlinks(self) -> None: async with api_client() as client: with oidc_user("alice"): vault_id = await _vault(client) key = await _key(client, vault_id) cert = await _cert(client, vault_id, key["id"], "Unlink") response = await client.delete(f"/api/vaults/{vault_id}/keys/{key['id']}") assert response.status_code == 204 response = await client.get(f"/api/vaults/{vault_id}/certificates/{cert['id']}") assert response.json()["key_id"] is None class ExtendedRouteTests(unittest.TestCase): @async_test async def test_preview_advanced_extensions(self) -> None: async with api_client() as client: with oidc_user("alice"): vault_id = await _vault(client) key = await _key(client, vault_id) payload = { "name": "preview", "key_id": key["id"], "subject": _subject("preview"), "extensions": { "basic_constraints": {"critical": True, "ca": False, "path_length": None}, "crl_distribution_points": {"critical": False, "urls": ["http://example.com/ca.crl"]}, "authority_information_access": { "critical": False, "descriptions": [{"method": "ocsp", "location": {"type": "uri", "value": "http://ocsp.example.com"}}], }, "raw": [{"oid": "1.2.3.4", "critical": False, "der_hex": "0c03616263"}], }, } response = await client.post(f"/api/vaults/{vault_id}/certificates/preview", json=payload) assert response.status_code == 200, response.text names = [row["name"] for row in response.json()["extensions"]] assert "CRL Distribution Points" in names assert "Authority Information Access" in names @async_test async def test_mandatory_dn_and_no_expiration(self) -> None: async with api_client() as client: with oidc_user("alice"): vault_id = await _vault(client) settings = (await client.get(f"/api/vaults/{vault_id}/settings")).json() await client.patch( f"/api/vaults/{vault_id}/settings", json={"settings": {"mandatory_dn": ["CN"]}, "version": settings["version"]}, ) key = await _key(client, vault_id) now = datetime.now(timezone.utc) response = await client.post( f"/api/vaults/{vault_id}/certificates", json={ "name": "missing cn", "key_id": key["id"], "subject": [{"name": "O", "value": "KCA"}], "validity": {"amount": 1, "unit": "days"}, }, ) assert response.status_code == 400 assert "mandatory" in response.json()["error"] response = await client.post( f"/api/vaults/{vault_id}/certificates", json={ "name": "forever", "key_id": key["id"], "subject": _subject("forever"), "validity": {"no_expiration": True}, }, ) assert response.status_code == 201, response.text assert response.json()["no_expiration"] is True @async_test async def test_ca_template_and_revocation_edit(self) -> None: async with api_client() as client: with oidc_user("alice"): vault_id = await _vault(client) ca_key = await _key(client, vault_id, "ca") ca = await _cert(client, vault_id, ca_key["id"], "CA", ca=True) response = await client.patch( f"/api/vaults/{vault_id}/certificates/{ca['id']}/ca", json={"crl_days": 45, "template_id": "builtin:tls-server"}, ) assert response.status_code == 200, response.text assert response.json()["default_template"] == "builtin:tls-server" assert response.json()["crl_days"] == 45 response = await client.post( f"/api/vaults/{vault_id}/certificates/{ca['id']}/revocations", json={"serial_hex": "1234", "reason": "keyCompromise", "invalidity_date": datetime.now(timezone.utc).isoformat()}, ) assert response.status_code == 201, response.text rev = response.json() response = await client.patch( f"/api/vaults/{vault_id}/certificates/{ca['id']}/revocations/{rev['id']}", json={"reason": "superseded", "invalidity_date": None}, ) assert response.status_code == 200, response.text assert response.json()["reason"] == "superseded" class VaultExportTests(unittest.TestCase): @async_test async def test_vault_zip_export(self) -> None: import io import zipfile async with api_client() as client: with oidc_user("alice"): vault_id = await _vault(client) key = await _key(client, vault_id, "zip key") cert = await _cert(client, vault_id, key["id"], "Zip Cert", ca=True) response = await client.post(f"/api/vaults/{vault_id}/export", json={"password": "secret"}) assert response.status_code == 200, response.text payload = response.json() data = base64.b64decode(payload["content_base64"]) with zipfile.ZipFile(io.BytesIO(data)) as archive: names = archive.namelist() assert "manifest.json" in names assert any(name.startswith("certificates/") for name in names) key_files = [name for name in names if name.startswith("keys/") and name.endswith(".key")] assert key_files assert b"BEGIN ENCRYPTED PRIVATE KEY" in archive.read(key_files[0]) manifest = archive.read("manifest.json") assert b"Zip Cert" in manifest class BulkRevocationTests(unittest.TestCase): @async_test async def test_bulk_revoke_reports_skips_and_errors(self) -> None: async with api_client() as client: with oidc_user("alice"): vault_id = await _vault(client) ca_key = await _key(client, vault_id, "bulk ca") ca = await _cert(client, vault_id, ca_key["id"], "Bulk CA", ca=True) leaf1_key = await _key(client, vault_id, "leaf1") leaf1 = await _cert(client, vault_id, leaf1_key["id"], "Leaf 1", signer_id=ca["id"]) leaf2_key = await _key(client, vault_id, "leaf2") leaf2 = await _cert(client, vault_id, leaf2_key["id"], "Leaf 2", signer_id=ca["id"]) self_key = await _key(client, vault_id, "self") self_signed = await _cert(client, vault_id, self_key["id"], "Self") response = await client.post( f"/api/vaults/{vault_id}/certificates/bulk-revoke", json={ "certificate_ids": [leaf1["id"], leaf2["id"], self_signed["id"]], "reason": "keyCompromise", }, ) assert response.status_code == 200, response.text result = response.json() assert len(result["revoked"]) == 2 assert len(result["errors"]) == 1 assert result["errors"][0]["id"] == self_signed["id"] response = await client.get(f"/api/vaults/{vault_id}/certificates/{leaf1['id']}") assert response.json()["revoked"] is True response = await client.post( f"/api/vaults/{vault_id}/certificates/bulk-revoke", json={"certificate_ids": [leaf1["id"], leaf2["id"]], "reason": "superseded"}, ) assert response.status_code == 200, response.text assert len(response.json()["skipped"]) == 2 response = await client.post( f"/api/vaults/{vault_id}/certificates/bulk-revoke", json={"certificate_ids": [], "reason": "superseded"}, ) assert response.status_code == 400 response = await client.post( f"/api/vaults/{vault_id}/certificates/bulk-revoke", json={"certificate_ids": ["not-a-uuid"], "reason": "superseded"}, ) assert response.status_code == 200 assert response.json()["errors"][0]["error"] == "invalid certificate id" @async_test async def test_bulk_revoke_validation_and_isolation(self) -> None: async with api_client() as client: with oidc_user("alice"): vault_id = await _vault(client) response = await client.post( f"/api/vaults/{vault_id}/certificates/bulk-revoke", json={"certificate_ids": ["00000000-0000-0000-0000-000000000000"], "reason": "superseded", "invalidity_date": "not-a-date"}, ) assert response.status_code == 400 response = await client.post( f"/api/vaults/{vault_id}/certificates/bulk-revoke", json={"certificate_ids": [str(i) for i in range(201)], "reason": "superseded"}, ) assert response.status_code == 400 with oidc_user("bob"): response = await client.post( f"/api/vaults/{vault_id}/certificates/bulk-revoke", json={"certificate_ids": ["00000000-0000-0000-0000-000000000000"], "reason": "superseded"}, ) assert response.status_code == 404 class OcspSnapshotTests(unittest.TestCase): @async_test async def test_snapshot_contains_status_and_reason(self) -> None: async with api_client() as client: with oidc_user("alice"): vault_id = await _vault(client) ca_key = await _key(client, vault_id, "ocsp ca") ca = await _cert(client, vault_id, ca_key["id"], "OCSP CA", ca=True) leaf_key = await _key(client, vault_id, "ocsp leaf") leaf = await _cert(client, vault_id, leaf_key["id"], "OCSP Leaf", signer_id=ca["id"]) await client.post( f"/api/vaults/{vault_id}/certificates/{leaf['id']}/revoke", json={"reason": "keyCompromise"}, ) response = await client.get( f"/api/vaults/{vault_id}/certificates/{ca['id']}/ocsp-status" ) assert response.status_code == 200, response.text payload = response.json() assert payload["enabled"] is False snapshot = payload["snapshot"] assert snapshot["schema"] == "kca-ocsp-status/v1" assert snapshot["issuer"]["sha256"] == ca["fingerprint_sha256"] entries = {entry["serial_hex"]: entry for entry in snapshot["entries"]} assert entries[leaf["serial_hex"]]["status"] == "revoked" assert entries[leaf["serial_hex"]]["reason"] == "keyCompromise" assert entries[leaf["serial_hex"]]["revoked_at"] @async_test async def test_revocation_marks_snapshot_dirty(self) -> None: from dataclasses import replace from unittest.mock import patch from kca import ocsp from kca.models import OcspSnapshot async with api_client() as client: with oidc_user("alice"): vault_id = await _vault(client) ca_key = await _key(client, vault_id, "dirty ca") ca = await _cert(client, vault_id, ca_key["id"], "Dirty CA", ca=True) leaf_key = await _key(client, vault_id, "dirty leaf") leaf = await _cert(client, vault_id, leaf_key["id"], "Dirty Leaf", signer_id=ca["id"]) configured = replace(ocsp.settings, ocsp_s3_bucket="test-bucket") with patch.object(ocsp, "settings", configured): response = await client.post( f"/api/vaults/{vault_id}/certificates/{leaf['id']}/revoke", json={"reason": "superseded"}, ) assert response.status_code == 200, response.text row = await OcspSnapshot.get(ca_id=ca["id"]) assert row.dirty is True @async_test async def test_publisher_marks_dirty_and_uploads(self) -> None: from dataclasses import replace from unittest.mock import patch from kca import ocsp from kca.models import OcspSnapshot class FakeS3: def __init__(self): self.calls = [] def put_object(self, **kwargs): self.calls.append(kwargs) fake = FakeS3() async with api_client() as client: with oidc_user("alice"): vault_id = await _vault(client) ca_key = await _key(client, vault_id, "ocsp publish ca") ca = await _cert(client, vault_id, ca_key["id"], "OCSP Publish CA", ca=True) configured = replace( ocsp.settings, ocsp_s3_bucket="test-bucket", ocsp_s3_prefix="ocsp", ) with patch.object(ocsp, "settings", configured), patch.object( ocsp, "_s3_client", lambda: fake ): await ocsp.mark_ocsp_dirty(ca["id"]) row = await OcspSnapshot.get(ca_id=ca["id"]) assert row.dirty is True async def ensure_context(): return None mixin = ocsp.OcspPublisherMixin(1, ensure_context) await mixin.run_once() assert len(fake.calls) == 1 call = fake.calls[0] assert call["Bucket"] == "test-bucket" assert call["Key"] == f"ocsp/{ca['id']}.json" assert b"kca-ocsp-status/v1" in call["Body"] row = await OcspSnapshot.get(ca_id=ca["id"]) assert row.dirty is False assert row.published_at is not None @async_test async def test_imported_ca_cannot_overwrite_other_vault_snapshot(self) -> None: import json from dataclasses import replace from unittest.mock import patch from kca import ocsp from kca.models import Certificate, OcspSnapshot class FakeS3: def __init__(self): self.objects = {} def put_object(self, **kwargs): self.objects[kwargs["Key"]] = kwargs["Body"] fake = FakeS3() async with api_client() as client: with oidc_user("alice"): vault_id = await _vault(client) ca_key = await _key(client, vault_id, "target ca") ca = await _cert(client, vault_id, ca_key["id"], "Target CA", ca=True) leaf_key = await _key(client, vault_id, "target leaf") leaf = await _cert( client, vault_id, leaf_key["id"], "Target Leaf", signer_id=ca["id"] ) response = await client.post( f"/api/vaults/{vault_id}/certificates/{leaf['id']}/revoke", json={"reason": "keyCompromise"}, ) assert response.status_code == 200, response.text exported = await client.post( f"/api/vaults/{vault_id}/certificates/{ca['id']}/export", json={"format": "pem"}, ) assert exported.status_code == 200, exported.text public_ca = exported.json()["content_base64"] configured = replace( ocsp.settings, ocsp_s3_bucket="test-bucket", ocsp_s3_prefix="ocsp", ) with patch.object(ocsp, "settings", configured), patch.object( ocsp, "_s3_client", lambda: fake ): target = await Certificate.get(id=ca["id"]) target_key = await ocsp.publish_snapshot(target) with oidc_user("bob"): attacker_vault_id = await _vault(client) payload = {"filename": "target-ca.crt", "content_base64": public_ca} response = await client.post( f"/api/vaults/{attacker_vault_id}/import", json=payload ) assert response.status_code == 200, response.text assert len(response.json()["imported"]) == 1 imported = await Certificate.get( vault_id=attacker_vault_id, fingerprint_sha256=ca["fingerprint_sha256"], ) assert imported.id != target.id assert imported.fingerprint_sha256 == target.fingerprint_sha256 row = await OcspSnapshot.get(ca_id=imported.id) assert row.dirty is True row.dirty = False await row.save(update_fields=["dirty", "updated_at"]) response = await client.post( f"/api/vaults/{attacker_vault_id}/import", json=payload ) assert response.status_code == 200, response.text assert len(response.json()["skipped"]) == 1 row = await OcspSnapshot.get(ca_id=imported.id) assert row.dirty is True imported_key = await ocsp.publish_snapshot(imported) assert target_key == f"ocsp/{ca['id']}.json" assert imported_key == f"ocsp/{imported.id}.json" assert imported_key != target_key assert set(fake.objects) == {target_key, imported_key} target_snapshot = json.loads(fake.objects[target_key]) target_entries = {entry["serial_hex"]: entry for entry in target_snapshot["entries"]} assert target_entries[leaf["serial_hex"].lower()]["status"] == "revoked" imported_snapshot = json.loads(fake.objects[imported_key]) imported_serials = {entry["serial_hex"] for entry in imported_snapshot["entries"]} assert leaf["serial_hex"].lower() not in imported_serials