import base64 import json import secrets import time from datetime import UTC, datetime, timedelta import pytest from cryptography import x509 from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from fastapi import FastAPI from fastapi.testclient import TestClient from k1link.fleet.registry import FleetRegistry from k1link.fleet.trust import SCHEMA, PairingError, parse_invitation, pem, public_id from k1link.web.fleet_api import router def code(value): return "MCN1." + base64.urlsafe_b64encode(json.dumps(value).encode()).decode().rstrip("=") @pytest.fixture def setup(tmp_path, monkeypatch): fleet = FleetRegistry(tmp_path) key = Ed25519PrivateKey.generate() node_id = public_id("node_", key.public_key()) invite = dict( schema=SCHEMA, node_id=node_id, id=secrets.token_urlsafe(32), secret=secrets.token_urlsafe(32), expires_at=int(time.time()) + 600, endpoint="https://192.168.20.4:8781", ) calls = [] def request(invitation, path, payload): calls.append((path, payload)) if path.endswith("/inspect"): return ( dict(schema=SCHEMA, node_id=node_id, name="Test board", host={"os": "Linux"}), key.public_key(), "192.168.20.5", ) if path.endswith("/offer"): return ( dict(node_id=node_id, receipt=secrets.token_urlsafe(32)), key.public_key(), "192.168.20.5", ) return dict(node_id=node_id), key.public_key(), "192.168.20.5" monkeypatch.setattr("k1link.fleet.registry.node_request", request) monkeypatch.setattr(fleet, "listen", lambda address: None) yield fleet, invite, key, calls fleet.close() def create(setup): fleet, invite, _, _ = setup preview = fleet.preview(code(invite)) row = fleet.add(preview["preview_id"], "Test vehicle", "ugv") return row, preview def heartbeat(row): return dict( schema=SCHEMA, node_id=row["node_id"], binding_id=row["binding"]["binding_id"], execution_binding={ "node_id": row["node_id"], "agent_instance_id": "agent_test", "platform": "linux", }, host={"hostname": "synthetic", "usb": [], "networks": []}, devices=[], ) def cert(row): return x509.load_pem_x509_certificate(row["binding"]["client_pem"].encode()).public_bytes( serialization.Encoding.DER ) def test_preview_add_is_durable_and_idempotent(setup): fleet, invite, _, calls = setup public, preview = create(setup) assert public["enrollment"] == "pending" assert public["connectivity"] == "offline" assert fleet.add(preview["preview_id"], "Test vehicle", "ugv")["id"] == public["id"] fleet.advance(public["id"]) assert fleet.listing()["items"][0]["enrollment"] == "paired" assert fleet.listing()["items"][0]["connectivity"] == "offline" row = fleet.find(public["id"]) assert row["invitation"] is None and row["receipt"] is None assert fleet.receive(cert(row), "/v1/node/heartbeat", heartbeat(row))[0] == 200 listed = fleet.listing() assert listed["items"][0]["connectivity"] == "online" assert invite["secret"] not in json.dumps(listed) assert "client_pem" not in json.dumps(listed) reopened = FleetRegistry(fleet.root) try: assert reopened.trust.core_id == fleet.trust.core_id assert reopened.listing()["items"][0]["id"] == public["id"] finally: reopened.close() def test_lost_commit_ack_recovers_from_authenticated_heartbeat(setup, monkeypatch): fleet, _, _, _ = setup public, _ = create(setup) original = __import__("k1link.fleet.registry", fromlist=["node_request"]).node_request def drop_ack(invitation, path, body): if path.endswith("/commit"): raise PairingError("Lost acknowledgement") return original(invitation, path, body) monkeypatch.setattr("k1link.fleet.registry.node_request", drop_ack) with pytest.raises(PairingError): fleet.advance(public["id"]) row = fleet.find(public["id"]) assert row["receipt"] and row["enrollment"] == "pending" assert fleet.receive(cert(row), "/v1/node/heartbeat", heartbeat(row))[0] == 200 assert fleet.find(public["id"])["enrollment"] == "paired" def test_revoke_during_network_completion_cannot_resurrect(setup, monkeypatch): fleet, _, _, _ = setup public, _ = create(setup) original = __import__("k1link.fleet.registry", fromlist=["node_request"]).node_request def revoke_first(invitation, path, body): value = original(invitation, path, body) fleet.revoke(public["id"]) return value monkeypatch.setattr("k1link.fleet.registry.node_request", revoke_first) fleet.advance(public["id"]) row = fleet.find(public["id"]) assert row["enrollment"] == "revoked" assert fleet.receive(cert(row), "/v1/node/heartbeat", heartbeat(row))[0] == 410 def test_repair_retains_vehicle_and_rejects_old_binding(setup): fleet, invite, _, _ = setup public, _ = create(setup) old = fleet.find(public["id"]) fleet.revoke(public["id"]) invite["id"] = secrets.token_urlsafe(32) updated, _ = create(setup) assert updated["id"] == public["id"] and updated["revision"] == 3 assert fleet.receive(cert(old), "/v1/node/heartbeat", heartbeat(old))[0] == 410 def test_rotation_has_bounded_old_certificate_grace(setup): fleet, _, key, _ = setup public, _ = create(setup) row = fleet.find(public["id"]) original = x509.load_pem_x509_certificate(row["binding"]["client_pem"].encode()) soon = ( x509.CertificateBuilder() .subject_name(original.subject) .issuer_name(original.issuer) .public_key(key.public_key()) .serial_number(x509.random_serial_number()) .not_valid_before(datetime.now(UTC) - timedelta(hours=1)) .not_valid_after(datetime.now(UTC) + timedelta(days=1)) .sign(fleet.trust.key, None) ) row["binding"]["client_pem"] = pem(soon) fleet.save(row) status, reply = fleet.receive(cert(row), "/v1/node/heartbeat", heartbeat(row)) assert status == 200 and reply["client_pem"] != pem(soon) assert fleet.receive(cert(row), "/v1/node/heartbeat", heartbeat(row))[0] == 200 current = fleet.find(public["id"]) current["certificate_previous"]["until"] = time.time() - 1 fleet.save(current) assert fleet.receive(cert(row), "/v1/node/heartbeat", heartbeat(row))[0] == 403 @pytest.mark.parametrize( "address", [ "https://8.8.8.8:8781", "https://127.0.0.1:8781", "https://192.168.1.2:22", "https://user@192.168.1.2:8781", "https://192.168.1.2:8781/api", "http://192.168.1.2:8781", "https://local.test:8781", ], ) def test_invitation_never_targets_public_loopback_dns_or_other_ports(setup, address): _, invite, _, _ = setup invite["endpoint"] = address with pytest.raises(PairingError): parse_invitation(code(invite)) def test_expiry_and_node_inventory_admission(setup): fleet, invite, _, _ = setup public, _ = create(setup) row = fleet.find(public["id"]) row["invitation"]["expires_at"] = 0 fleet.save(row) fleet.advance(public["id"]) assert fleet.find(public["id"])["enrollment"] == "failed" invite["expires_at"] = 0 with pytest.raises(PairingError): parse_invitation(code(invite)) def test_operator_api_rejects_cross_origin_and_remote_peer(setup): fleet, _, _, _ = setup app = FastAPI() app.state.fleet = fleet app.include_router(router) with TestClient(app, base_url="http://127.0.0.1:8000", client=("127.0.0.1", 55555)) as client: assert client.get("/api/v1/fleet").status_code == 200 assert ( client.get("/api/v1/fleet", headers={"Origin": "https://attacker.test"}).status_code == 403 ) assert ( client.get("/api/v1/fleet", headers={"Host": "attacker.test:8000"}).status_code == 403 ) assert client.post("/api/v1/fleet/preview", json={"code": "bad"}).status_code == 409 with TestClient( app, base_url="http://127.0.0.1:8000", client=("192.168.20.6", 55555) ) as client: assert client.get("/api/v1/fleet").status_code == 403 @pytest.mark.parametrize("wrong_identity", [False, True]) def test_bootstrap_pin_is_verified_before_secret_is_sent(setup, monkeypatch, wrong_identity): from cryptography.x509.oid import NameOID from k1link.fleet.trust import node_request _, invite, key, _ = setup if wrong_identity: key = Ed25519PrivateKey.generate() name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "Synthetic Node")]) certificate = ( x509.CertificateBuilder() .subject_name(name) .issuer_name(name) .public_key(key.public_key()) .serial_number(x509.random_serial_number()) .not_valid_before(datetime.now(UTC) - timedelta(minutes=1)) .not_valid_after(datetime.now(UTC) + timedelta(hours=1)) .sign(key, None) .public_bytes(serialization.Encoding.DER) ) sent = [] class Socket: def getpeercert(self, **_kwargs): return certificate def getsockname(self): return ("192.168.20.5", 41000) class Response: status = 200 def read(self, _limit): return b'{"ok":true}' class Connection: sock = Socket() def __init__(self, *_args, **_kwargs): pass def connect(self): pass def request(self, *args): sent.append(args) def getresponse(self): return Response() def close(self): pass monkeypatch.setattr("k1link.fleet.trust.http.client.HTTPSConnection", Connection) if wrong_identity: with pytest.raises(PairingError): node_request(invite, "/v1/pair/inspect", {"secret": invite["secret"]}) assert not sent else: assert node_request(invite, "/v1/pair/inspect", {"secret": invite["secret"]})[0] == { "ok": True } assert len(sent) == 1