SIENTIAPDE-1973: Implement per-connector OPC client certificates and remove legacy read engine. This update overhauls certificate management, transitioning from a single platform certificate to individual client certificates defined in the slot configuration. The CertProvider now downloads certificates from MinIO based on certificate_id, applying dynamic security policies (mode, policy) from the slot. The legacy READ_ENGINE=legacy option and OpcManager have been entirely removed, standardizing on the ServerReader with strict slot v2 enforcement. Write command resolution (ServerConfigResolver) now consistently uses connectors joined with opc_certificates. The CERT_BUCKET environment variable is no longer used.
160 lines
5.8 KiB
Python
160 lines
5.8 KiB
Python
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'
|