plate-config-app/tests/test_auth_service.py
Danil Iarosh add8aff0af feat: release version 2.0.0
- Добавлена обратная совместимость с прошивками, содержащими маркер no_auth.

- Повторная авторизация после команды 04 и успешного обновления прошивки приведена к требуемому порядку.

- В терминал команд добавлена ручная кнопка авторизации.

- Реализовано OTA-обновление приложения с загрузкой платформенного артефакта, безопасной заменой и перезапуском.

- Проверка обновлений учитывает только более новые версии по схеме major, minor и patch.

- Обновлены имена Windows и Linux сборок, документация и тестовое покрытие.
2026-09-16 10:01:35 +03:00

263 lines
8.9 KiB
Python

from __future__ import annotations
import unittest
from collections import deque
from unittest.mock import patch
from app.services.auth_service import (
AuthStatus,
_aes_encrypt,
check_status,
firmware_requires_auth,
perform_handshake,
perform_required_handshake,
perform_startup_handshake,
wait_for_startup_banner,
)
class FakeConnection:
def __init__(self, responses: list[str | None]) -> None:
self.is_connected = True
self._responses = deque(responses)
self.events: list[str] = []
self.release_count = 0
self.firmware_version: str | None = None
self.auth_required: bool | None = None
def read_response(self, timeout: float | None = None) -> str | None:
response = self._responses.popleft() if self._responses else None
self.events.append(f"read:{response}")
return response
def send_command(self, command: str) -> None:
self.events.append(f"send:{command}")
def write_raw(self, command: str) -> None:
self.events.append(f"write:{command}")
def release(self) -> None:
self.release_count += 1
def set_firmware_version(self, version: str, *, auth_required: bool) -> None:
self.firmware_version = version
self.auth_required = auth_required
class StartupHandshakeTests(unittest.TestCase):
_HOST_CHALLENGE = bytes.fromhex("00112233445566778899AABBCCDDEEFF")
_CHAL = "CHAL00112233445566778899AABBCCDDEEFF"
_RAES = "RAES40E9A18C22C2CFCD764666C90B358F9D"
def _perform_handshake(
self,
conn: FakeConnection,
*,
timeout: float = 0.1,
ready_timeout: float = 0.1,
):
with patch(
"app.services.auth_service.os.urandom",
return_value=self._HOST_CHALLENGE,
):
return perform_handshake(
conn,
timeout=timeout,
ready_timeout=ready_timeout,
)
def test_waits_for_banner_and_two_seconds_before_sending_auth(self) -> None:
conn = FakeConnection([
"boot log",
"periphery controller ver_2.3.6 release",
self._CHAL,
self._RAES,
"AUTH_PC_OK",
"READY!",
])
sleep_calls: list[float] = []
def fake_sleep(seconds: float) -> None:
sleep_calls.append(seconds)
conn.events.append(f"sleep:{seconds}")
with patch(
"app.services.auth_service.os.urandom",
return_value=self._HOST_CHALLENGE,
):
result = perform_startup_handshake(
conn,
startup_delay=2.0,
sleep_fn=fake_sleep,
)
self.assertEqual(AuthStatus.OK, result.status)
self.assertTrue(result.startup_ready)
self.assertEqual([2.0], sleep_calls)
sleep_index = conn.events.index("sleep:2.0")
send_index = next(
index for index, event in enumerate(conn.events) if event.startswith("send:07")
)
self.assertLess(sleep_index, send_index)
self.assertEqual("2.3.6 release", conn.firmware_version)
self.assertTrue(conn.auth_required)
def test_no_auth_banner_skips_handshake(self) -> None:
conn = FakeConnection([
"boot log",
"periphery controller ver_2.3.6_No_Auth_prizes",
])
sleep_calls: list[float] = []
result = perform_startup_handshake(
conn,
startup_delay=2.0,
sleep_fn=sleep_calls.append,
)
self.assertEqual(AuthStatus.OK, result.status)
self.assertTrue(result.startup_ready)
self.assertTrue(result.auth_skipped)
self.assertEqual("2.3.6_No_Auth_prizes", result.firmware_version)
self.assertFalse(conn.auth_required)
self.assertEqual([2.0], sleep_calls)
self.assertFalse(any(event.startswith("send:07") for event in conn.events))
self.assertEqual(1, conn.release_count)
def test_auth_mode_marker_is_case_insensitive(self) -> None:
self.assertFalse(firmware_requires_auth("2.3.6_no_auth_prizes"))
self.assertFalse(firmware_requires_auth("2.3.6_NO_AUTH"))
self.assertTrue(firmware_requires_auth("2.3.6_release"))
def test_manual_handshake_is_skipped_for_known_no_auth_firmware(self) -> None:
conn = FakeConnection([])
conn.set_firmware_version("2.3.6_no_auth", auth_required=False)
result = perform_required_handshake(conn, timeout=0.1)
self.assertEqual(AuthStatus.OK, result.status)
self.assertTrue(result.auth_skipped)
self.assertFalse(any(event.startswith("send:") for event in conn.events))
def test_banner_requires_non_empty_version_suffix(self) -> None:
conn = FakeConnection(["periphery controller ver_", None])
banner = wait_for_startup_banner(conn, timeout=0.1)
self.assertIsNone(banner)
self.assertFalse(any(event.startswith("send:") for event in conn.events))
def test_startup_timeout_never_sends_auth_command(self) -> None:
conn = FakeConnection(["unrelated boot log", None])
result = perform_startup_handshake(
conn,
banner_timeout=0.1,
startup_delay=2.0,
sleep_fn=lambda _seconds: self.fail("Пауза не должна начинаться без banner"),
)
self.assertEqual(AuthStatus.TIMEOUT, result.status)
self.assertFalse(result.startup_ready)
self.assertFalse(any(event.startswith("send:") for event in conn.events))
def test_raes_does_not_complete_handshake(self) -> None:
conn = FakeConnection([self._CHAL, self._RAES, None])
result = self._perform_handshake(conn)
self.assertEqual(AuthStatus.TIMEOUT, result.status)
self.assertIn("READY!", result.detail)
def test_auth_pc_ok_does_not_complete_handshake(self) -> None:
conn = FakeConnection([self._CHAL, self._RAES, "AUTH_PC_OK", None])
result = self._perform_handshake(conn)
self.assertEqual(AuthStatus.TIMEOUT, result.status)
self.assertIn("READY!", result.detail)
def test_ready_without_exclamation_mark_is_rejected(self) -> None:
conn = FakeConnection([self._CHAL, self._RAES, "READY", None])
result = self._perform_handshake(conn)
self.assertEqual(AuthStatus.TIMEOUT, result.status)
def test_exact_ready_bang_completes_handshake(self) -> None:
conn = FakeConnection([
self._CHAL,
self._RAES,
"AUTH_PC_OK",
" READY! ",
])
result = self._perform_handshake(conn)
self.assertEqual(AuthStatus.OK, result.status)
self.assertEqual(1, conn.release_count)
def test_effective_aes_key_matches_controller_profile(self) -> None:
encrypted = _aes_encrypt(self._HOST_CHALLENGE)
self.assertEqual("40E9A18C22C2CFCD764666C90B358F9D", encrypted.hex().upper())
def test_invalid_raes_fails_controller_verification(self) -> None:
conn = FakeConnection([
self._CHAL,
"RAES00000000000000000000000000000000",
"READY!",
])
result = self._perform_handshake(conn)
self.assertEqual(AuthStatus.FAILED, result.status)
self.assertIn("RAES", result.detail)
def test_status_requires_exact_ready_bang(self) -> None:
ready_conn = FakeConnection(["READY!"])
not_ready_conn = FakeConnection(["READY", None])
self.assertEqual(AuthStatus.OK, check_status(ready_conn, timeout=0.1))
self.assertEqual(AuthStatus.NEED_AUTH, check_status(not_ready_conn, timeout=0.1))
def test_no_auth_status_accepts_any_non_empty_response(self) -> None:
conn = FakeConnection(["OK"])
conn.set_firmware_version("2.3.6_no_auth", auth_required=False)
self.assertEqual(AuthStatus.OK, check_status(conn, timeout=0.1))
def test_no_auth_status_timeout_is_connection_failure(self) -> None:
conn = FakeConnection([None])
conn.set_firmware_version("2.3.6_no_auth", auth_required=False)
self.assertEqual(AuthStatus.DISCONNECTED, check_status(conn, timeout=0.1))
def test_new_secure_banner_reenables_handshake(self) -> None:
conn = FakeConnection([
"periphery controller ver_2.0.0_release",
self._CHAL,
self._RAES,
"READY!",
])
conn.set_firmware_version("1.0.0_no_auth", auth_required=False)
with patch(
"app.services.auth_service.os.urandom",
return_value=self._HOST_CHALLENGE,
):
result = perform_startup_handshake(
conn,
startup_delay=0.0,
sleep_fn=lambda _seconds: None,
)
self.assertEqual(AuthStatus.OK, result.status)
self.assertFalse(result.auth_skipped)
self.assertTrue(conn.auth_required)
self.assertTrue(any(event.startswith("send:07") for event in conn.events))
if __name__ == "__main__":
unittest.main()