Files
NODEDC_MISSION_CORE/tests/fleet/test_replacement.py
T

163 lines
7.6 KiB
Python

"""Replacement uses synthetic identities; no device or network access."""
import json
import secrets
import sqlite3
import time
from datetime import UTC, datetime, timedelta
import pytest
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from fastapi import FastAPI
from fastapi.testclient import TestClient
from test_pairing import setup, create, code, cert, heartbeat
from k1link.fleet import board_layout
from k1link.fleet.registry import FleetRegistry
from k1link.fleet.trust import SCHEMA, PairingError, public_id
from k1link.web.fleet_api import router
def candidate(fleet, monkeypatch):
key = Ed25519PrivateKey.generate()
invite = dict(schema=SCHEMA, node_id=public_id('node_', key.public_key()),
id=secrets.token_urlsafe(32), secret=secrets.token_urlsafe(32),
expires_at=int(time.time())+600, endpoint='https://192.168.20.8:8781')
def request(invitation, path, payload):
return dict(schema=SCHEMA, node_id=invite['node_id'], name='Replacement board',
host={'os':'Linux'}, receipt=secrets.token_urlsafe(32)), key.public_key(), '192.168.20.5'
monkeypatch.setattr('k1link.fleet.registry.node_request', request)
return fleet.preview(code(invite))
def current(setup):
fleet = setup[0]
public, _ = create(setup)
fleet.advance(public['id'])
return fleet.find(public['id'])
def attach(fleet, old, preview, **kwargs):
return fleet.add(preview['preview_id'], '', '', vehicle_id=old['id'],
expected_revision=kwargs.get('revision', old['revision']))
def test_replacement_keeps_vehicle_and_layout_but_not_old_authority(setup, monkeypatch):
fleet = setup[0]
old = current(setup)
board_layout.update(fleet.root, old['id'], 'settings', False)
before_layout = board_layout.read(fleet.root, old['id'])
preview = candidate(fleet, monkeypatch)
new = attach(fleet, old, preview)
assert new['id'] == old['id'] and new['name'] == old['name'] and new['platform'] == old['platform']
assert new['node_id'] == preview['node_id'] != old['node_id']
assert new['revision'] == old['revision']+1 and new['enrollment'] == 'pending'
assert new['host'] is None and new['sensor_state']['items'] == []
assert board_layout.read(fleet.root, old['id']) == before_layout
assert fleet.find(old['id'])['created_at'] == old['created_at']
archived = fleet.db.execute('SELECT body FROM board_history WHERE vehicle_id=?', (old['id'],)).fetchone()[0]
assert json.loads(archived) == old
assert fleet.db.execute('SELECT node_id FROM vehicles').fetchone()[0] == preview['node_id']
assert fleet.receive(cert(old), '/v1/node/heartbeat', heartbeat(old))[0] == 410
assert attach(fleet, old, preview) == new # A lost operator response is retried safely.
fresh = fleet.find(old['id'])
assert fleet.receive(cert(fresh), '/v1/node/heartbeat', heartbeat(fresh))[0] == 200
assert fleet.public(fleet.find(old['id']))['connectivity'] == 'online'
reopened = FleetRegistry(fleet.root)
try:
assert reopened.find(old['id'])['node_id'] == preview['node_id']
assert reopened.db.execute('SELECT count(*) FROM board_history').fetchone()[0] == 1
finally:
reopened.close()
def test_stale_and_expired_preview_do_not_touch_existing_binding(setup, monkeypatch):
fleet = setup[0]
old = current(setup)
preview = candidate(fleet, monkeypatch)
with pytest.raises(PairingError, match='изменилась'):
attach(fleet, old, preview, revision=old['revision']+1)
fleet.previews[preview['preview_id']]['expires'] = 0
with pytest.raises(PairingError, match='истекла'):
attach(fleet, old, preview)
assert fleet.find(old['id']) == old
assert fleet.db.execute('SELECT count(*) FROM board_history').fetchone()[0] == 0
def test_other_apparatus_computer_is_never_silently_transferred(setup, monkeypatch):
fleet = setup[0]
old = current(setup)
preview = candidate(fleet, monkeypatch)
verified = dict(fleet.previews[preview['preview_id']])
other = fleet.add(preview['preview_id'], 'Other apparatus', 'ugv')
with pytest.raises(PairingError):
attach(fleet, old, preview)
# Another verified preview of the same computer cannot transfer its owner.
preview['preview_id'] = secrets.token_urlsafe(32)
fleet.previews[preview['preview_id']] = verified
with pytest.raises(PairingError, match='другому аппарату'):
attach(fleet, old, preview)
assert fleet.find(old['id']) == old
assert len(fleet.rows()) == 2
@pytest.mark.parametrize('activity', ['pairing', 'control', 'device', 'wireless'])
def test_replacement_rejects_active_work(setup, monkeypatch, activity):
fleet = setup[0]
old = current(setup)
if activity == 'pairing':
old['enrollment'] = 'pending'
elif activity == 'control':
fleet.rover_control.exchange(old['node_id'], {'relay_id':'a'*32, 'rover':{'instance':'test','supported':True,'state':'idle'}})
fleet.rover_control.arm(old['node_id'], {'standstill_confirmed':True,'current_a':5,'max_erpm':1000})
elif activity == 'device':
old['sensor_commands'] = {'synthetic':{'state':'running','command':{'deadline_at':(datetime.now(UTC)+timedelta(seconds=60)).isoformat()}}}
else:
fleet.device_enrollment.pending[(old['id'],'synthetic')] = dict(binding=old['binding']['binding_id'], created=time.time(),deadline=time.time()+60,payload={},public={'state':'running'})
fleet.save(old)
preview = candidate(fleet, monkeypatch)
with pytest.raises(PairingError):
attach(fleet, old, preview)
assert fleet.find(old['id']) == old
def test_failed_transaction_preserves_old_binding_and_history(setup, monkeypatch):
fleet = setup[0]
old = current(setup)
preview = candidate(fleet, monkeypatch)
fleet.db.execute("CREATE TRIGGER fail_replace BEFORE UPDATE ON vehicles BEGIN SELECT RAISE(ABORT, 'synthetic failure'); END")
with pytest.raises(sqlite3.IntegrityError):
attach(fleet, old, preview)
assert fleet.find(old['id']) == old
assert fleet.db.execute('SELECT count(*) FROM board_history').fetchone()[0] == 0
def test_listener_failure_keeps_original_and_preview_usable(setup, monkeypatch):
fleet = setup[0]
old = current(setup)
preview = candidate(fleet, monkeypatch)
def fail(_):
raise OSError('synthetic')
monkeypatch.setattr(fleet, 'listen', fail)
with pytest.raises(PairingError, match='частный канал'):
attach(fleet, old, preview)
assert fleet.find(old['id']) == old
monkeypatch.setattr(fleet, 'listen', lambda _:None)
assert attach(fleet, old, preview)['id'] == old['id']
def test_attach_endpoint_scoped_local_revision_and_payload(setup, monkeypatch):
fleet = setup[0]
old = current(setup)
preview = candidate(fleet, monkeypatch)
app = FastAPI(); app.state.fleet = fleet; app.include_router(router)
body = {'preview_id':preview['preview_id'],'expected_revision':old['revision']}
with TestClient(app, base_url='http://127.0.0.1:8000', client=('127.0.0.1',55555)) as client:
path = '/api/v1/fleet/'+old['id']+'/computer'
assert client.post(path, json=body, headers={'Origin':'https://attacker.test'}).status_code == 403
assert client.post(path, json={**body,'name':'injected'}).status_code == 422
assert client.post(path, json={**body,'expected_revision':0}).status_code == 422
response = client.post(path, json=body)
assert response.status_code == 200
assert response.json()['id'] == old['id']
assert old['binding']['binding_id'] not in response.text