163 lines
7.6 KiB
Python
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
|