Skip to content

Commit c9a4704

Browse files
committed
test(security): Add tests for the MAVLink signing functionality
1 parent a28f488 commit c9a4704

8 files changed

Lines changed: 2250 additions & 1 deletion

scripts/run_sitl_tests.sh

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,8 +80,9 @@ run_sitl_tests() {
8080
# Set environment variable for SITL binary
8181
export SITL_BINARY="$SITL_BINARY"
8282

83-
# Run only SITL-marked tests
83+
# Run SITL tests including signing integration tests
8484
python -m pytest tests/test_backend_flightcontroller_sitl.py \
85+
tests/bdd_signing_sitl_integration.py \
8586
-v \
8687
--tb=short \
8788
--capture=no \
Lines changed: 313 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,313 @@
1+
#!/usr/bin/env python3
2+
3+
"""
4+
BDD-style tests for FlightController MAVLink signing methods.
5+
6+
This file is part of ArduPilot Methodic Configurator. https://github.com/ArduPilot/MethodicConfigurator
7+
8+
SPDX-FileCopyrightText: 2024-2025 Amilcar do Carmo Lucas <amilcar.lucas@iav.de>
9+
10+
SPDX-License-Identifier: GPL-3.0-or-later
11+
"""
12+
13+
import time
14+
from unittest.mock import MagicMock
15+
16+
import pytest
17+
18+
from ardupilot_methodic_configurator.backend_flightcontroller import FlightController
19+
20+
# pylint: disable=redefined-outer-name, unused-argument
21+
22+
23+
@pytest.fixture
24+
def mock_master() -> MagicMock:
25+
"""Create a mock MAVLink master connection."""
26+
mock = MagicMock()
27+
mock.setup_signing = MagicMock()
28+
mock.disable_signing = MagicMock()
29+
return mock
30+
31+
32+
@pytest.fixture
33+
def flight_controller(mock_master: MagicMock) -> FlightController:
34+
"""Create a FlightController instance with a mocked master connection."""
35+
mock_connection_manager = MagicMock()
36+
mock_connection_manager.master = mock_master
37+
38+
return FlightController(connection_manager=mock_connection_manager)
39+
40+
41+
class TestFlightControllerSigningSetup:
42+
"""Test MAVLink signing setup functionality in BDD style."""
43+
44+
def test_setup_signing_with_valid_parameters(self, flight_controller, mock_master) -> None:
45+
"""
46+
User can set up MAVLink signing with valid parameters.
47+
48+
GIVEN: A FlightController with an active connection
49+
AND: A valid 32-byte signing key
50+
WHEN: The user calls setup_signing
51+
THEN: It should succeed and return True
52+
AND: The master connection should be configured with the correct parameters
53+
"""
54+
key = b"0" * 32
55+
sign_outgoing = True
56+
allow_unsigned_in = False
57+
initial_timestamp = 0 # Use current time
58+
link_id = 1
59+
60+
result = flight_controller.setup_signing(
61+
key,
62+
sign_outgoing=sign_outgoing,
63+
allow_unsigned_in=allow_unsigned_in,
64+
initial_timestamp=initial_timestamp,
65+
link_id=link_id,
66+
)
67+
assert result is True
68+
mock_master.setup_signing.assert_called_once()
69+
call_args = mock_master.setup_signing.call_args
70+
assert call_args[0][0] == key
71+
assert call_args[1]["sign_outgoing"] == sign_outgoing
72+
assert call_args[1]["allow_unsigned_callback"] is None
73+
assert call_args[1]["initial_timestamp"] == initial_timestamp
74+
assert call_args[1]["link_id"] == link_id
75+
76+
def test_setup_signing_with_callback_enabled(self, flight_controller, mock_master) -> None:
77+
"""
78+
User can set up signing with unsigned callback enabled.
79+
80+
GIVEN: A FlightController with an active connection
81+
WHEN: The user calls setup_signing with allow_unsigned_in=True
82+
THEN: The master connection should be configured with the unsigned callback
83+
"""
84+
key = b"1" * 32
85+
flight_controller.setup_signing(key, allow_unsigned_in=True)
86+
call_args = mock_master.setup_signing.call_args
87+
assert call_args[1]["allow_unsigned_callback"] == flight_controller._unsigned_callback # pylint: disable=protected-access
88+
89+
def test_setup_signing_without_connection_raises_error(self) -> None:
90+
"""
91+
Setting up signing fails if no connection is present.
92+
93+
GIVEN: A FlightController with NO active connection (master is None)
94+
WHEN: The user calls setup_signing
95+
THEN: It should raise ConnectionError
96+
"""
97+
mock_connection_manager = MagicMock()
98+
mock_connection_manager.master = None
99+
fc = FlightController(connection_manager=mock_connection_manager)
100+
key = b"0" * 32
101+
with pytest.raises(ConnectionError, match="No flight controller connection"):
102+
fc.setup_signing(key)
103+
104+
def test_setup_signing_invalid_key_length_raises_error(self, flight_controller) -> None:
105+
"""
106+
Setting up signing fails with invalid key length.
107+
108+
GIVEN: A valid FlightController connection
109+
BUT: An invalid signing key (not 32 bytes)
110+
WHEN: The user calls setup_signing
111+
THEN: It should raise ValueError
112+
"""
113+
invalid_key = b"too_short"
114+
with pytest.raises(ValueError, match="must be 32 bytes"):
115+
flight_controller.setup_signing(invalid_key)
116+
117+
def test_setup_signing_invalid_link_id_raises_error(self, flight_controller) -> None:
118+
"""
119+
Setting up signing fails with invalid link ID.
120+
121+
GIVEN: A valid FlightController connection and key
122+
BUT: An invalid link_id (out of range)
123+
WHEN: The user calls setup_signing
124+
THEN: It should raise ValueError
125+
"""
126+
key = b"0" * 32
127+
invalid_link_id = 256
128+
with pytest.raises(ValueError, match="link_id must be between"):
129+
flight_controller.setup_signing(key, link_id=invalid_link_id)
130+
131+
def test_setup_signing_not_supported_raises_error(self, flight_controller, mock_master) -> None:
132+
"""
133+
Setting up signing fails if pymavlink version doesn't support it.
134+
135+
GIVEN: A connected FlightController
136+
BUT: The underlying library raises AttributeError during setup
137+
WHEN: The user calls setup_signing
138+
THEN: It should raise NotImplementedError
139+
"""
140+
key = b"0" * 32
141+
mock_master.setup_signing.side_effect = AttributeError("Method not found")
142+
with pytest.raises(NotImplementedError, match="not supported"):
143+
flight_controller.setup_signing(key)
144+
145+
def test_setup_signing_generic_failure_raises_runtime_error(self, flight_controller, mock_master) -> None:
146+
"""
147+
Setting up signing handles generic failures gracefully.
148+
149+
GIVEN: A connected FlightController
150+
BUT: The setup raises an unexpected exception
151+
WHEN: The user calls setup_signing
152+
THEN: It should raise RuntimeError
153+
"""
154+
key = b"0" * 32
155+
mock_master.setup_signing.side_effect = Exception("Unknown error")
156+
with pytest.raises(RuntimeError, match="Failed to set up"):
157+
flight_controller.setup_signing(key)
158+
159+
def test_setup_signing_rejects_old_timestamp(self, flight_controller) -> None:
160+
"""
161+
Setting up signing rejects old timestamps to prevent replay attacks.
162+
163+
GIVEN: A FlightController with an active connection
164+
AND: A valid signing key
165+
BUT: A timestamp older than 7 days
166+
WHEN: The user calls setup_signing
167+
THEN: It should raise ValueError with replay attack warning
168+
"""
169+
key = b"0" * 32
170+
# Create a timestamp 8 days in the past (7 day limit)
171+
eight_days_ago = int((time.time() - 8 * 86400) * 1e6)
172+
173+
with pytest.raises(ValueError, match="days old - rejecting to prevent replay attack"):
174+
flight_controller.setup_signing(key, initial_timestamp=eight_days_ago)
175+
176+
def test_setup_signing_accepts_recent_timestamp(self, flight_controller, mock_master) -> None:
177+
"""
178+
Setting up signing accepts timestamps within 7-day window.
179+
180+
GIVEN: A FlightController with an active connection
181+
AND: A timestamp from 6 days ago (within limit)
182+
WHEN: The user calls setup_signing
183+
THEN: It should succeed
184+
"""
185+
key = b"0" * 32
186+
# Create a timestamp 6 days in the past (within 7 day limit)
187+
six_days_ago = int((time.time() - 6 * 86400) * 1e6)
188+
189+
result = flight_controller.setup_signing(key, initial_timestamp=six_days_ago)
190+
assert result is True
191+
192+
def test_unsigned_callback_logs_security_warnings(self, flight_controller) -> None:
193+
"""
194+
Unsigned message callback logs security warnings for monitoring.
195+
196+
GIVEN: A FlightController with signing configured to allow unsigned messages
197+
WHEN: An unsigned message is received
198+
THEN: The callback should log a security warning
199+
AND: The callback should accept the message (permissive mode)
200+
"""
201+
mock_msg = MagicMock()
202+
mock_msg.get_type = MagicMock(return_value="HEARTBEAT")
203+
204+
# Test the callback directly (internal method)
205+
result = flight_controller._unsigned_callback(mock_msg) # pylint: disable=protected-access
206+
207+
assert result is True # Permissive mode accepts all
208+
mock_msg.get_type.assert_called_once()
209+
210+
211+
class TestFlightControllerSigningDisable:
212+
"""Test disabling MAVLink signing functionality in BDD style."""
213+
214+
def test_disable_signing_success(self, flight_controller, mock_master) -> None:
215+
"""
216+
User can disable MAVLink signing successfully.
217+
218+
GIVEN: A FlightController with an active connection
219+
WHEN: The user calls disable_signing
220+
THEN: It should succeed and return True
221+
AND: The master connection's setup_signing should be called with None key
222+
"""
223+
result = flight_controller.disable_signing()
224+
assert result is True
225+
mock_master.setup_signing.assert_called_once()
226+
call_args = mock_master.setup_signing.call_args
227+
assert call_args[0][0] is None
228+
assert call_args[1]["sign_outgoing"] is False
229+
assert call_args[1]["allow_unsigned_callback"] is None
230+
231+
def test_disable_signing_without_connection_raises_error(self) -> None:
232+
"""
233+
Disabling signing fails if no connection is present.
234+
235+
GIVEN: A FlightController with NO active connection
236+
WHEN: The user calls disable_signing
237+
THEN: It should raise ConnectionError
238+
"""
239+
mock_connection_manager = MagicMock()
240+
mock_connection_manager.master = None
241+
fc = FlightController(connection_manager=mock_connection_manager)
242+
243+
with pytest.raises(ConnectionError, match="No flight controller connection"):
244+
fc.disable_signing()
245+
246+
def test_disable_signing_not_supported_raises_error(self, flight_controller, mock_master) -> None:
247+
"""
248+
Disabling signing fails if pymavlink version doesn't support it.
249+
250+
GIVEN: A connected FlightController
251+
BUT: The underlying library raises AttributeError during setup
252+
WHEN: The user calls disable_signing
253+
THEN: It should raise NotImplementedError
254+
"""
255+
mock_master.setup_signing.side_effect = AttributeError("Method not found")
256+
with pytest.raises(NotImplementedError, match="not supported"):
257+
flight_controller.disable_signing()
258+
259+
260+
class TestFlightControllerSigningStatus:
261+
"""Test retrieving MAVLink signing status in BDD style."""
262+
263+
def test_get_signing_status_success(self, flight_controller, mock_master) -> None:
264+
"""
265+
User can retrieve current signing status.
266+
267+
GIVEN: A connected FlightController with signing enabled
268+
WHEN: The user calls get_signing_status
269+
THEN: It should return the dictionary reflecting the signing state
270+
"""
271+
mock_signing = MagicMock()
272+
mock_signing.sign_outgoing = True
273+
mock_signing.allow_unsigned_callback = lambda x: True # noqa: ARG005
274+
mock_signing.link_id = 42
275+
276+
mock_master.mav = MagicMock()
277+
mock_master.mav.signing = mock_signing
278+
279+
status = flight_controller.get_signing_status()
280+
assert status["enabled"] is True
281+
assert status["sign_outgoing"] is True
282+
assert status["allow_unsigned"] is True
283+
assert status["link_id"] == 42
284+
assert "enabled" in status["message"]
285+
286+
def test_get_signing_status_not_configured(self, flight_controller, mock_master) -> None:
287+
"""
288+
User gets correct status when signing is not configured.
289+
290+
GIVEN: A connected FlightController without signing
291+
WHEN: The user calls get_signing_status
292+
THEN: It should return a disabled status
293+
"""
294+
mock_master.mav = MagicMock()
295+
mock_master.mav.signing = None
296+
status = flight_controller.get_signing_status()
297+
assert status["enabled"] is False
298+
assert "not configured" in status["message"]
299+
300+
def test_get_signing_status_no_connection(self) -> None:
301+
"""
302+
User gets correct status when no connection exists.
303+
304+
GIVEN: A FlightController with NO active connection
305+
WHEN: The user calls get_signing_status
306+
THEN: It should return a default disconnected status
307+
"""
308+
mock_connection_manager = MagicMock()
309+
mock_connection_manager.master = None
310+
fc = FlightController(connection_manager=mock_connection_manager)
311+
status = fc.get_signing_status()
312+
assert status["enabled"] is False
313+
assert "No connection" in str(status["message"])

0 commit comments

Comments
 (0)