SIENTIAPDE-2202 Add gateway end-to-end test suite (e2e/) End-to-end suite at the repo root, following the Laborious/Orchestrator pattern: numbered scenarios per topic in e2e/scenarios.md (1:1 with test_scenario_* functions), scenario inputs as JSON, Postgres DDL mirrored from production, `integration` marker, excluded from the default pytest run. Every dependency runs for real: MongoDB, Redis, PostgreSQL, MinIO and NATS via testcontainers, OPC UA servers in process (asyncua). Pods are built from the environment like ingestor/app.py and driven one loop iteration at a time. 69 scenarios across 7 topics: slot leasing and failover, subscription reads, NATS writes and write_status (incl. the SIENTIAPDE-2202 timestamp/quality matrix, checked on the server and on the wire), gRPC ReadTag, active reads, client certificates/security, pod lifecycle. Replaces tests/functional (legacy slot v1 tests, fully commented out) and tests/functional/test_write_e2e.py (now scenarios 3.1.x). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
166 lines
5.7 KiB
Python
166 lines
5.7 KiB
Python
"""E2E scenarios — 6. Client certificates and OPC security (MinIO + opc_certificates). See ``e2e/scenarios.md``."""
|
|
|
|
import pytest
|
|
import pytest_asyncio
|
|
|
|
from e2e.helpers import (
|
|
insert_prediction,
|
|
load_scenario_input,
|
|
notifications,
|
|
readings,
|
|
stays_false,
|
|
wait_until,
|
|
write_status,
|
|
)
|
|
|
|
pytestmark = [pytest.mark.integration, pytest.mark.asyncio]
|
|
|
|
TIMESTAMP = '2026-10-13T12:00:00-03:00'
|
|
SECURED = {'mode': 'SignAndEncrypt', 'policy': 'Basic256Sha256'}
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def secured_server(opc_servers):
|
|
"""plant-s: only Basic256Sha256 SignAndEncrypt endpoints, accepts any client certificate."""
|
|
return await opc_servers('plant-s', {'Temperature': 20.0, 'Setpoint': 0.0}, secured=True)
|
|
|
|
|
|
def secured_slot(server, certificate, **security):
|
|
slot = load_scenario_input('slot_secured.json', {'plant-s': server})
|
|
slot['plant-s']['certificate'] = certificate
|
|
slot['plant-s']['security'] = {**SECURED, **security}
|
|
return slot
|
|
|
|
|
|
def secured_connector(server, certificate_id):
|
|
return {
|
|
'connector_id': '7',
|
|
'type': 'opc_ua',
|
|
'name': 'plant-s',
|
|
'url': server.url,
|
|
'status': 'Active',
|
|
'subscription_period_ms': 100,
|
|
'security': {**SECURED, 'client_certificate_id': certificate_id},
|
|
}
|
|
|
|
|
|
def write_command():
|
|
return load_scenario_input(
|
|
'write_command.json', server_id='7', model_id='2', timestamp=TIMESTAMP
|
|
)
|
|
|
|
|
|
# 6.1 Secured connections ---------------------------------------------------------------
|
|
|
|
|
|
async def test_scenario_6_1_1_secured_read_with_certificate_from_minio(
|
|
secured_server, client_certificate, pods, put_slot, mongo_db, cert_cache_dir
|
|
):
|
|
certificate = await client_certificate(7)
|
|
put_slot(1, secured_slot(secured_server, certificate))
|
|
pod = await pods('pod-a')
|
|
|
|
await pod.tick()
|
|
|
|
await secured_server.set('Temperature', 21.0)
|
|
await wait_until(lambda: readings(mongo_db, 'raw_secured', value=21.0))
|
|
assert sorted(p.name for p in (cert_cache_dir / '7').iterdir()) == ['cert.der', 'key.pem']
|
|
assert notifications(mongo_db, 'OPC_CONNECTION_ERROR') == []
|
|
|
|
|
|
async def test_scenario_6_1_2_secured_write_resolved_from_connectors_and_certificates(
|
|
secured_server, client_certificate, pods, mongo_db, postgres_engine, publish_command
|
|
):
|
|
await client_certificate(7)
|
|
mongo_db['connectors'].insert_one(secured_connector(secured_server, 7))
|
|
insert_prediction(postgres_engine, 2, TIMESTAMP, 'pending')
|
|
await pods('pod-a')
|
|
|
|
await publish_command(write_command())
|
|
|
|
await wait_until(lambda: write_status(postgres_engine, 2, TIMESTAMP) == 'ok')
|
|
assert (await secured_server.read('Setpoint')).Value.Value == 1.25
|
|
|
|
|
|
# 6.2 Failures --------------------------------------------------------------------------
|
|
|
|
|
|
async def test_scenario_6_2_1_certificate_missing_in_minio(
|
|
secured_server, client_certificate, pods, put_slot, mongo_db, cert_cache_dir
|
|
):
|
|
certificate = await client_certificate(7, upload=False)
|
|
put_slot(1, secured_slot(secured_server, certificate))
|
|
pod = await pods('pod-a')
|
|
|
|
await pod.tick()
|
|
|
|
assert notifications(mongo_db, 'OPC_CONNECTION_ERROR_plant-s')
|
|
assert pod.manager.opc_managers == {}
|
|
assert not list((cert_cache_dir / '7').glob('*.der'))
|
|
assert await stays_false(lambda: readings(mongo_db, 'raw_secured'))
|
|
|
|
|
|
async def test_scenario_6_2_2_unsupported_security_policy(
|
|
secured_server, client_certificate, pods, put_slot, mongo_db
|
|
):
|
|
certificate = await client_certificate(7)
|
|
put_slot(1, secured_slot(secured_server, certificate, policy='Basic999'))
|
|
pod = await pods('pod-a')
|
|
|
|
await pod.tick()
|
|
|
|
[error] = notifications(mongo_db, 'OPC_CONNECTION_ERROR_plant-s')
|
|
assert 'Unsupported OPC security' in error['message']
|
|
assert pod.manager.opc_managers == {}
|
|
|
|
|
|
async def test_scenario_6_2_3_no_certificate_cannot_reach_a_secured_only_server(
|
|
secured_server, pods, put_slot, mongo_db
|
|
):
|
|
put_slot(1, secured_slot(secured_server, None))
|
|
pod = await pods('pod-a')
|
|
|
|
await pod.tick()
|
|
|
|
assert notifications(mongo_db, 'OPC_CONNECTION_ERROR_plant-s')
|
|
assert await stays_false(lambda: readings(mongo_db, 'raw_secured'))
|
|
|
|
|
|
async def test_scenario_6_2_4_inactive_certificate_write_fails(
|
|
secured_server, client_certificate, pods, mongo_db, postgres_engine, publish_command
|
|
):
|
|
await client_certificate(7, status='revoked')
|
|
mongo_db['connectors'].insert_one(secured_connector(secured_server, 7))
|
|
insert_prediction(postgres_engine, 2, TIMESTAMP, 'pending')
|
|
await pods('pod-a')
|
|
|
|
await publish_command(write_command())
|
|
|
|
await wait_until(lambda: write_status(postgres_engine, 2, TIMESTAMP) == 'error')
|
|
assert secured_server.writes == []
|
|
|
|
|
|
# 6.3 Rotation --------------------------------------------------------------------------
|
|
|
|
|
|
async def test_scenario_6_3_1_certificate_rotation_reconnects_with_the_new_one(
|
|
secured_server, client_certificate, pods, put_slot, mongo_db, cert_cache_dir
|
|
):
|
|
first = await client_certificate(7)
|
|
put_slot(1, secured_slot(secured_server, first))
|
|
pod = await pods('pod-a')
|
|
await pod.tick()
|
|
await wait_until(lambda: readings(mongo_db, 'raw_secured'))
|
|
first_client = pod.manager.pool._connections['7'][0]
|
|
|
|
second = await client_certificate(8)
|
|
put_slot(1, secured_slot(secured_server, second))
|
|
await pod.tick()
|
|
|
|
assert pod.manager.pool._connections['7'][0] is not first_client
|
|
assert pod.manager.pool.config_for('7')['certificate']['certificate_id'] == 8
|
|
assert (cert_cache_dir / '8' / 'cert.der').is_file()
|
|
await secured_server.set('Temperature', 22.0)
|
|
await wait_until(lambda: readings(mongo_db, 'raw_secured', value=22.0))
|
|
await wait_until(lambda: secured_server.session_count() == 1)
|