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