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')