"""Tests for client certificate management.""" from __future__ import annotations from pathlib import Path import pytest from bouncer.cert import ( cert_path, delete_cert, fingerprint, generate_cert, generate_listener_cert, has_cert, list_certs, listener_cert_path, ) @pytest.fixture def data_dir(tmp_path: Path) -> Path: """Provide a temporary data directory.""" return tmp_path class TestGenerateListenerCert: def test_creates_pem_with_cn_bouncer(self, data_dir: Path) -> None: from cryptography import x509 as x509_mod from cryptography.x509.oid import NameOID pem = generate_listener_cert(data_dir) assert pem.is_file() assert pem == listener_cert_path(data_dir) cert_data = pem.read_bytes() cert_obj = x509_mod.load_pem_x509_certificate(cert_data) cn = cert_obj.subject.get_attributes_for_oid(NameOID.COMMON_NAME)[0].value assert cn == "bouncer" content = pem.read_text() assert "BEGIN CERTIFICATE" in content assert "BEGIN PRIVATE KEY" in content mode = pem.stat().st_mode & 0o777 assert mode == 0o600 def test_idempotent(self, data_dir: Path) -> None: pem1 = generate_listener_cert(data_dir) fp1 = fingerprint(pem1) mtime1 = pem1.stat().st_mtime pem2 = generate_listener_cert(data_dir) fp2 = fingerprint(pem2) mtime2 = pem2.stat().st_mtime assert pem1 == pem2 assert fp1 == fp2 assert mtime1 == mtime2 # file not regenerated class TestCertPath: def test_standard_path(self, data_dir: Path) -> None: p = cert_path(data_dir, "libera", "fabesune") assert p == data_dir / "certs" / "libera" / "fabesune.pem" class TestGenerateCert: def test_creates_pem_file(self, data_dir: Path) -> None: pem = generate_cert(data_dir, "libera", "testnick") assert pem.is_file() assert pem == cert_path(data_dir, "libera", "testnick") def test_pem_contains_cert_and_key(self, data_dir: Path) -> None: pem = generate_cert(data_dir, "libera", "testnick") content = pem.read_text() assert "BEGIN CERTIFICATE" in content assert "BEGIN PRIVATE KEY" in content def test_file_permissions(self, data_dir: Path) -> None: pem = generate_cert(data_dir, "libera", "testnick") mode = pem.stat().st_mode & 0o777 assert mode == 0o600 def test_overwrites_existing(self, data_dir: Path) -> None: pem1 = generate_cert(data_dir, "libera", "testnick") fp1 = fingerprint(pem1) pem2 = generate_cert(data_dir, "libera", "testnick") fp2 = fingerprint(pem2) assert pem1 == pem2 assert fp1 != fp2 # New cert = new fingerprint def test_custom_validity_days(self, data_dir: Path) -> None: from cryptography import x509 as x509_mod pem = generate_cert(data_dir, "libera", "testnick", validity_days=365) cert_data = pem.read_bytes() cert_obj = x509_mod.load_pem_x509_certificate(cert_data) delta = cert_obj.not_valid_after_utc - cert_obj.not_valid_before_utc assert 364 <= delta.days <= 366 class TestFingerprint: def test_format(self, data_dir: Path) -> None: pem = generate_cert(data_dir, "libera", "testnick") fp = fingerprint(pem) parts = fp.split(":") assert len(parts) == 32 # SHA-256 = 32 bytes for part in parts: assert len(part) == 2 int(part, 16) # Must be valid hex def test_uppercase_hex(self, data_dir: Path) -> None: pem = generate_cert(data_dir, "libera", "testnick") fp = fingerprint(pem) assert fp == fp.upper() def test_deterministic_for_same_cert(self, data_dir: Path) -> None: pem = generate_cert(data_dir, "libera", "testnick") assert fingerprint(pem) == fingerprint(pem) class TestHasCert: def test_exists(self, data_dir: Path) -> None: generate_cert(data_dir, "libera", "testnick") assert has_cert(data_dir, "libera", "testnick") is True def test_not_exists(self, data_dir: Path) -> None: assert has_cert(data_dir, "libera", "testnick") is False class TestDeleteCert: def test_delete_existing(self, data_dir: Path) -> None: generate_cert(data_dir, "libera", "testnick") assert delete_cert(data_dir, "libera", "testnick") is True assert has_cert(data_dir, "libera", "testnick") is False def test_delete_nonexistent(self, data_dir: Path) -> None: assert delete_cert(data_dir, "libera", "testnick") is False def test_cleans_empty_dir(self, data_dir: Path) -> None: generate_cert(data_dir, "libera", "testnick") delete_cert(data_dir, "libera", "testnick") assert not (data_dir / "certs" / "libera").exists() class TestListCerts: def test_empty(self, data_dir: Path) -> None: assert list_certs(data_dir) == [] def test_list_all(self, data_dir: Path) -> None: generate_cert(data_dir, "libera", "nick1") generate_cert(data_dir, "oftc", "nick2") certs = list_certs(data_dir) assert len(certs) == 2 networks = {c[0] for c in certs} assert networks == {"libera", "oftc"} def test_list_by_network(self, data_dir: Path) -> None: generate_cert(data_dir, "libera", "nick1") generate_cert(data_dir, "oftc", "nick2") certs = list_certs(data_dir, network="libera") assert len(certs) == 1 assert certs[0][0] == "libera" assert certs[0][1] == "nick1" def test_list_multiple_per_network(self, data_dir: Path) -> None: generate_cert(data_dir, "libera", "nick1") generate_cert(data_dir, "libera", "nick2") certs = list_certs(data_dir, network="libera") assert len(certs) == 2 nicks = {c[1] for c in certs} assert nicks == {"nick1", "nick2"} def test_fingerprints_present(self, data_dir: Path) -> None: generate_cert(data_dir, "libera", "testnick") certs = list_certs(data_dir) assert len(certs) == 1 _, _, fp = certs[0] assert ":" in fp assert len(fp.split(":")) == 32