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.
132 lines
4.0 KiB
Python
132 lines
4.0 KiB
Python
import pytest
|
|
|
|
from ingestor.io.slots import CONNECTION_FIELDS, parse_slot
|
|
|
|
SECURITY = {'mode': 'SignAndEncrypt', 'policy': 'Basic256Sha256'}
|
|
CERTIFICATE = {
|
|
'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'},
|
|
}
|
|
NAME = {'collection': 'c', 'tag_name': 't', 'model_name': 'm', 'model_id': '1'}
|
|
|
|
RAW = {
|
|
'server1': {
|
|
'server_id': '1',
|
|
'name': 'server1',
|
|
'url': 'opc.tcp://h:4840',
|
|
'subscription_period_ms': 1000,
|
|
'security': SECURITY,
|
|
'certificate': CERTIFICATE,
|
|
'tags': {
|
|
'ns=2;i=2': {
|
|
'frequency': '15000',
|
|
'active_read': {'enabled': True, 'cycle_multiple': 3},
|
|
'names': [
|
|
{
|
|
'collection': 'raw_x',
|
|
'tag_name': 'Counter',
|
|
'model_name': 'm1',
|
|
'model_id': '1',
|
|
},
|
|
{
|
|
'collection': 'raw_y',
|
|
'tag_name': 'Contador',
|
|
'model_name': 'm2',
|
|
'model_id': '2',
|
|
},
|
|
],
|
|
}
|
|
},
|
|
}
|
|
}
|
|
|
|
|
|
def _server(**fields):
|
|
server = {
|
|
'server_id': '1',
|
|
'url': 'u',
|
|
'security': {'mode': 'None', 'policy': 'None'},
|
|
'certificate': None,
|
|
'tags': {'ns=2;i=1': {'frequency': '1000', 'names': [NAME]}},
|
|
}
|
|
return {'s': {**server, **fields}}
|
|
|
|
|
|
def test_parse_slot_multi_name():
|
|
slot = parse_slot(RAW)
|
|
tag = slot['server1']['tags']['ns=2;i=2']
|
|
assert len(tag['names']) == 2
|
|
assert tag['active_read']['cycle_multiple'] == 3
|
|
|
|
|
|
def test_parse_slot_keeps_security_and_certificate():
|
|
server = parse_slot(RAW)['server1']
|
|
|
|
assert server['security'] == SECURITY
|
|
assert server['certificate'] == CERTIFICATE
|
|
assert {field: server[field] for field in CONNECTION_FIELDS} == {
|
|
'url': 'opc.tcp://h:4840',
|
|
'subscription_period_ms': 1000,
|
|
'security': SECURITY,
|
|
'certificate': CERTIFICATE,
|
|
}
|
|
|
|
|
|
def test_parse_slot_without_certificate_key_means_no_certificate():
|
|
raw = _server()
|
|
del raw['s']['certificate']
|
|
|
|
server = parse_slot(raw)['s']
|
|
|
|
assert server['certificate'] is None
|
|
assert server['name'] == 's' # falls back to the slot key
|
|
|
|
|
|
def test_parse_slot_defaults_active_read_disabled():
|
|
tag = parse_slot(_server())['s']['tags']['ns=2;i=1']
|
|
assert tag['active_read'] == {'enabled': False}
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
'raw',
|
|
[
|
|
pytest.param(_server(tags={'ns=2;i=1': {'frequency': '1000'}}), id='tag-without-names'),
|
|
pytest.param(
|
|
_server(tags={'ns=2;i=1': {'frequency': '1', 'names': [{'collection': 'c'}]}}),
|
|
id='name-without-tag-name',
|
|
),
|
|
pytest.param(
|
|
_server(
|
|
tags={
|
|
'ns=2;i=1': {
|
|
'frequency': '1',
|
|
'names': [{'collection': 'c', 'tag_name': 't'}],
|
|
}
|
|
}
|
|
),
|
|
id='name-without-model-id',
|
|
),
|
|
pytest.param(_server(security=None), id='no-security'),
|
|
# Slot v1 (an Orchestrator before SIENTIAPDE-1974): no longer accepted
|
|
pytest.param(
|
|
{
|
|
'kepserver': {
|
|
'server_id': '1',
|
|
'url': 'opc.tcp://kepserver:49320',
|
|
'server_uri': 'urn:sientia:opc-ingestor',
|
|
'cert_path': '/app/certs/opc-ingestor-cert.pem',
|
|
'tags': {
|
|
'ns=2;s=A': {'tag_name': 'A', 'frequency': 30000, 'topics': ['raw_x']}
|
|
},
|
|
}
|
|
},
|
|
id='slot-v1',
|
|
),
|
|
],
|
|
)
|
|
def test_parse_slot_rejects_invalid_slots(raw):
|
|
with pytest.raises((KeyError, ValueError)):
|
|
parse_slot(raw)
|