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.
150 lines
5.3 KiB
Python
150 lines
5.3 KiB
Python
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
import pytest
|
|
|
|
from ingestor.connection.servers import CONNECTORS, ServerConfigResolver
|
|
|
|
LOOKED_UP = {
|
|
'certificate_id': 1,
|
|
'application_uri': 'urn:sientia:opc-ingestor',
|
|
'cert': {'bucket': 'opc-certificates', 'object_name': 'client/p/cert.pem'},
|
|
'private_key': {'bucket': 'opc-certificates', 'object_name': 'client/p/key.pem'},
|
|
}
|
|
# A connector as the aggregation returns it ($lookup on opc_certificates)
|
|
CONNECTOR = {
|
|
'connector_id': 'kepserver',
|
|
'url': 'opc.tcp://kepserver:49320',
|
|
'subscription_period_ms': 500,
|
|
'security': {
|
|
'mode': 'SignAndEncrypt',
|
|
'policy': 'Basic256Sha256',
|
|
'client_certificate_id': 1,
|
|
},
|
|
'certificates': [LOOKED_UP],
|
|
}
|
|
# The same server in the slot v2 format
|
|
CONFIG = {
|
|
'server_id': 'kepserver',
|
|
'url': 'opc.tcp://kepserver:49320',
|
|
'subscription_period_ms': 500,
|
|
'security': {'mode': 'SignAndEncrypt', 'policy': 'Basic256Sha256'},
|
|
'certificate': LOOKED_UP,
|
|
}
|
|
|
|
|
|
def make_resolver(connector=None, pooled=None, ttl_s=60.0):
|
|
pool = MagicMock()
|
|
pool.config_for = MagicMock(return_value=pooled)
|
|
mongo = MagicMock()
|
|
mongo.aggregate = AsyncMock(return_value=[connector] if connector is not None else [])
|
|
return ServerConfigResolver(pool, mongo, MagicMock(), ttl_s=ttl_s)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_live_pooled_connection_wins_over_mongo():
|
|
"""Writes reuse exactly the settings the read path opened the connection with."""
|
|
pooled = {'url': 'opc.tcp://read:1', 'security': CONFIG['security'], 'certificate': None}
|
|
resolver = make_resolver(connector=CONNECTOR, pooled=pooled)
|
|
|
|
assert await resolver.resolve('kepserver') == pooled
|
|
resolver.pool.config_for.assert_called_once_with('kepserver')
|
|
resolver.mongo.aggregate.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_connector_with_its_certificate_in_the_slot_format():
|
|
resolver = make_resolver(connector=CONNECTOR)
|
|
|
|
assert await resolver.resolve('kepserver') == CONFIG
|
|
collection, pipeline, _ = resolver.mongo.aggregate.await_args.args
|
|
assert collection == CONNECTORS
|
|
assert pipeline[0] == {
|
|
'$match': {'connector_id': 'kepserver', 'type': 'opc_ua', 'status': 'Active'}
|
|
}
|
|
# The same rule as the orchestrator: only an active client certificate (D5)
|
|
[lookup] = [stage['$lookup'] for stage in pipeline if '$lookup' in stage]
|
|
assert lookup['from'] == 'opc_certificates'
|
|
assert lookup['pipeline'][0]['$match']['status'] == 'active'
|
|
assert lookup['pipeline'][0]['$match']['kind'] == 'client'
|
|
resolver.logger.warning.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_connector_without_certificate_id_connects_without_security():
|
|
connector = {
|
|
**CONNECTOR,
|
|
'security': {'mode': 'None', 'policy': 'None', 'client_certificate_id': None},
|
|
'certificates': [],
|
|
}
|
|
resolver = make_resolver(connector=connector)
|
|
|
|
cfg = await resolver.resolve('kepserver')
|
|
|
|
assert cfg['certificate'] is None
|
|
assert cfg['security'] == {'mode': 'None', 'policy': 'None'}
|
|
resolver.logger.warning.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_certificate_id_without_an_active_client_certificate_warns():
|
|
resolver = make_resolver(connector={**CONNECTOR, 'certificates': []})
|
|
|
|
cfg = await resolver.resolve('kepserver')
|
|
|
|
assert cfg == {**CONFIG, 'certificate': None}
|
|
assert 'client_certificate_id 1' in resolver.logger.warning.call_args.args[0]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
'connector',
|
|
[
|
|
pytest.param({**CONNECTOR, 'url': ''}, id='no-url'),
|
|
pytest.param({**CONNECTOR, 'security': {'mode': 'Sign'}}, id='no-policy'),
|
|
pytest.param({k: v for k, v in CONNECTOR.items() if k != 'security'}, id='no-security'),
|
|
],
|
|
)
|
|
async def test_incomplete_connectors_are_not_found(connector):
|
|
resolver = make_resolver(connector=connector)
|
|
|
|
assert await resolver.resolve('kepserver') is None
|
|
resolver.logger.warning.assert_called_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unknown_server_is_cached_briefly():
|
|
resolver = make_resolver(ttl_s=60.0)
|
|
resolver.missing_ttl_s = 10.0
|
|
resolver.clock = MagicMock()
|
|
resolver.clock.elapsed.side_effect = [0.0, 5.0, 11.0]
|
|
|
|
assert await resolver.resolve('9') is None
|
|
assert await resolver.resolve('9') is None # within missing_ttl_s: no lookup
|
|
assert await resolver.resolve('9') is None # expired: looked up again
|
|
assert resolver.mongo.aggregate.await_count == 2
|
|
assert resolver.logger.warning.call_count == 2
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_lookups_are_cached_for_the_ttl():
|
|
resolver = make_resolver(connector=CONNECTOR, ttl_s=60.0)
|
|
resolver.clock = MagicMock()
|
|
resolver.clock.elapsed.side_effect = [0.0, 30.0, 61.0]
|
|
|
|
first = await resolver.resolve('kepserver')
|
|
first['url'] = 'mutated by the caller'
|
|
second = await resolver.resolve('kepserver') # within the TTL: cached, and a copy
|
|
await resolver.resolve('kepserver') # expired: looked up again
|
|
|
|
assert second['url'] == 'opc.tcp://kepserver:49320'
|
|
assert resolver.mongo.aggregate.await_count == 2
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_mongo_errors_propagate_to_the_caller():
|
|
resolver = make_resolver()
|
|
resolver.mongo.aggregate = AsyncMock(side_effect=RuntimeError('mongo down'))
|
|
|
|
with pytest.raises(RuntimeError, match='mongo down'):
|
|
await resolver.resolve('kepserver')
|