import asyncio from unittest.mock import AsyncMock, MagicMock import pytest from ingestor.connection.certs import CertificateNotConfiguredError, CertProvider, LazyMinio def certificate(certificate_id=1, name='sientia-platform', ext='pem', bucket='opc-certificates'): """A client certificate as the slot v2 carries it.""" return { 'certificate_id': certificate_id, 'application_uri': 'urn:sientia:opc-ingestor', 'cert': {'bucket': bucket, 'object_name': f'client/{name}/cert.{ext}'}, 'private_key': {'bucket': bucket, 'object_name': f'client/{name}/key.{ext}'}, } def _provider(tmp_path, data=b'pem'): minio = MagicMock() minio.download_file = AsyncMock(return_value=data) return CertProvider(minio=minio, cache_dir=str(tmp_path)), minio @pytest.mark.asyncio async def test_fetch_downloads_the_objects_the_certificate_names(tmp_path): minio = AsyncMock() minio.download_file.side_effect = [b'cert-bytes', b'key-bytes'] provider = CertProvider(minio, cache_dir=str(tmp_path)) cert, key = await provider.fetch(certificate(7)) assert (cert, key) == (str(tmp_path / '7' / 'cert.pem'), str(tmp_path / '7' / 'key.pem')) assert [c.args for c in minio.download_file.await_args_list] == [ ('client/sientia-platform/cert.pem', 'opc-certificates', {}), ('client/sientia-platform/key.pem', 'opc-certificates', {}), ] assert (tmp_path / '7' / 'cert.pem').read_bytes() == b'cert-bytes' assert (tmp_path / '7' / 'key.pem').read_bytes() == b'key-bytes' @pytest.mark.asyncio async def test_local_files_keep_the_object_extension(tmp_path): """asyncua picks the format (.pem / .der) by the extension""" provider, _minio = _provider(tmp_path) cert, key = await provider.fetch(certificate(ext='der')) assert cert.endswith('cert.der') and key.endswith('key.der') @pytest.mark.asyncio @pytest.mark.parametrize( 'broken', [ pytest.param({'cert': None}, id='no-cert'), pytest.param({'private_key': {'bucket': 'b'}}, id='key-without-object'), pytest.param({'certificate_id': None}, id='no-id'), pytest.param({'certificate_id': '../etc'}, id='id-not-an-integer'), ], ) async def test_fetch_of_an_incomplete_certificate_raises_clear_error(tmp_path, broken): provider, minio = _provider(tmp_path) with pytest.raises(CertificateNotConfiguredError, match='opc_certificates|certificate_id'): await provider.fetch({**certificate(), **broken}) minio.download_file.assert_not_awaited() @pytest.mark.asyncio async def test_fetch_is_cached_per_certificate_until_invalidated(tmp_path): provider, minio = _provider(tmp_path) first = await provider.fetch(certificate(1)) assert await provider.fetch(certificate(1)) == first assert minio.download_file.await_count == 2 # cert + key, once other = await provider.fetch(certificate(2, name='other')) assert other != first assert minio.download_file.await_count == 4 provider.invalidate(1) await provider.fetch(certificate(1)) await provider.fetch(certificate(2, name='other')) # still cached assert minio.download_file.await_count == 6 @pytest.mark.asyncio async def test_a_replaced_certificate_is_downloaded_again(tmp_path): """Same certificate_id, other objects in the slot: the cache does not serve the old files""" provider, minio = _provider(tmp_path) await provider.fetch(certificate(1, name='old')) await provider.fetch(certificate(1, name='new')) assert minio.download_file.await_count == 4 assert minio.download_file.await_args_list[-1].args[0] == 'client/new/key.pem' @pytest.mark.asyncio async def test_concurrent_fetches_download_once(tmp_path): provider, minio = _provider(tmp_path) results = await asyncio.gather(*(provider.fetch(certificate()) for _ in range(5))) assert len(set(results)) == 1 assert minio.download_file.await_count == 2 @pytest.mark.asyncio async def test_download_replaces_file_atomically(tmp_path): provider, _minio = _provider(tmp_path, data=b'new') (tmp_path / '1').mkdir() (tmp_path / '1' / 'cert.pem').write_bytes(b'old') cert_path, _ = await provider.fetch(certificate(1)) assert (tmp_path / '1' / 'cert.pem').read_bytes() == b'new' assert cert_path == str(tmp_path / '1' / 'cert.pem') assert not list(tmp_path.glob('**/*.tmp')) @pytest.mark.asyncio async def test_lazy_minio_builds_one_client_per_bucket_on_first_download(): clients = {'a': AsyncMock(), 'b': AsyncMock()} for client in clients.values(): client.download_file.return_value = b'bytes' factory = MagicMock(side_effect=lambda bucket: clients[bucket]) minio = LazyMinio(factory) factory.assert_not_called() # no network at construction assert await minio.download_file('x.pem', 'a', {}) == b'bytes' await minio.download_file('y.pem', 'a', {}) await minio.download_file('z.pem', 'b', {}) assert [c.args for c in factory.call_args_list] == [('a',), ('b',)] assert clients['a'].download_file.await_count == 2 clients['b'].download_file.assert_awaited_once_with('z.pem', 'b', {}) def test_build_cert_provider_is_lazy_and_uses_config(monkeypatch): from ingestor.config import GatewayConfig from ingestor.connection import certs minio_cls = MagicMock() monkeypatch.setattr(certs, 'MinioRepository', minio_cls) provider = certs.build_cert_provider( config=GatewayConfig(cert_cache_dir='certs-cache'), logger=MagicMock(), notification_handler=MagicMock(), metrics_controller=MagicMock(), ) assert provider.cache_dir == 'certs-cache' assert isinstance(provider.minio, certs.LazyMinio) minio_cls.assert_not_called() # MinIO is only reached when a cert is needed provider.minio._factory('opc-certificates') assert minio_cls.call_args.kwargs['bucket'] == 'opc-certificates'