"""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