sprint-2.5: RBAC 4 roles + Studio bootable + Flash Console
End-to-end implementation per docs/sprint-2.5-plan.md. New requirement added by user mid-sprint: 4-role RBAC (Super Admin / Engineer / Owner / User) with dual-auth for Engineer flashing firmware, plus a "mini Arduino IDE" inside the Studio. Tests: pytest 231/231 green (129 Sprint 2 + 102 Sprint 2.5 new). RBAC core (arautopilot/core/): - rbac.py: 4 roles, 12 capabilities, immutable capability matrix, has() / capabilities_of() / require() / requires_dual_auth() helpers. Engineer flashing firmware needs SA approval; everything else is single-factor. - user.py: User model with PBKDF2-HMAC-SHA256 PIN hashing (200k iters, 16-byte salt, self-describing hash format for future migrations). 4-8 digit numeric PINs enforced. - user_store.py: JSON-backed user database. seed_demo_users() for first-run UX. - audit.py: append-only JSONL audit log. AuditEvent with timestamp, user_id, role, action, target, outcome, reason, secondary_user_id for dual-auth, optional extra payload. Crypto signing of lines deferred to Sprint 8. Studio GUI (arautopilot/studio/): - app.py: real entry point (replaces Sprint 0 stub). --seed-demo populates demo users without launching GUI; --data-dir overrides the ~/.ar-autopilot/studio/ default. - session.py: Session + SessionHolder. check() always audits the decision; verify_super_admin_pin() + log_dual_auth_grant() for dual-auth flows. - login_window.py: modal login dialog with user picker + PIN field. Audits login attempts (success and bad-PIN denials). - main_window.py: top-level window with sidebar (user + role + caps) and tab area (Overview, Flash Console, Project placeholder, Telemetry placeholder). - flash_console.py: the "mini Arduino IDE". Lists serial ports via pyserial; picks firmware variant (esp32-dev / esp32-debug); compiles via 'pio run'; flashes via 'pio run -t upload --upload-port <port>'; streams pio output to a dark-themed read-only console; supports cancel. For Engineer flashes, asks the Super Admin for their PIN inline before invoking pio. Records dual-auth grant + pio exit code in the audit log. Dependencies: - New [project.optional-dependencies] group 'studio': PySide6>=6.6, pyserial>=3.5, platformio>=6.1. Kept optional so the core can be installed in lean / CI environments. Tests (arautopilot/tests/): - test_rbac.py: 32 tests for capability matrix, dual-auth policy, no-privilege-escalation invariants, partial overlap between roles. - test_user.py: 11 tests for PIN hashing, verification, salting, serialisation, field validators. - test_audit.py: 9 tests for JSONL append, immutability, round-trip, corrupt-line detection, dual-auth event shape, blank-line tolerance. - test_user_store.py: 10 tests for CRUD, persistence, role filtering, demo seed idempotency. - test_session.py: 9 tests for capability checks + audit side effects, SA PIN verification, dual-auth recording, SessionHolder lifecycle. - test_studio_smoke.py: 5 headless tests verifying Studio modules import without a display server, --seed-demo works, helpers safe to call without hardware. NOT in Sprint 2.5 (intentional): - Crypto signing of audit log lines (hash-chain) -- Sprint 8 - HWID binding of the user store -- Sprint 8 - Project configurator + .appack compiler -- Sprint 4 - Flutter bridge display -- Sprint 4 - Telemetry dashboard tab -- Sprint 4 - Serial monitor as a separate tab -- future enhancement Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
"""Tests for ``arautopilot.core.audit``."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from arautopilot.core.audit import AuditEvent, AuditLog, AuditOutcome
|
||||
|
||||
|
||||
def test_event_to_jsonl_is_single_line() -> None:
|
||||
ev = AuditEvent(
|
||||
action="engage_pilot",
|
||||
outcome=AuditOutcome.SUCCESS,
|
||||
user_id="u123",
|
||||
role="user",
|
||||
)
|
||||
line = ev.to_jsonl()
|
||||
assert "\n" not in line
|
||||
assert line.startswith("{") and line.endswith("}")
|
||||
|
||||
|
||||
def test_event_is_immutable() -> None:
|
||||
ev = AuditEvent(action="x", outcome=AuditOutcome.SUCCESS)
|
||||
with pytest.raises((TypeError, ValueError)):
|
||||
ev.action = "y" # type: ignore[misc]
|
||||
|
||||
|
||||
def test_append_and_read_round_trip(tmp_path: Path) -> None:
|
||||
log = AuditLog(tmp_path / "audit.jsonl")
|
||||
e1 = AuditEvent(action="login", outcome=AuditOutcome.SUCCESS, user_id="u1")
|
||||
e2 = AuditEvent(action="engage", outcome=AuditOutcome.DENIED, user_id="u2",
|
||||
reason="missing capability")
|
||||
log.append(e1)
|
||||
log.append(e2)
|
||||
read = log.read_all()
|
||||
assert len(read) == 2
|
||||
assert read[0].action == "login"
|
||||
assert read[1].outcome is AuditOutcome.DENIED
|
||||
|
||||
|
||||
def test_len_counts_lines(tmp_path: Path) -> None:
|
||||
log = AuditLog(tmp_path / "audit.jsonl")
|
||||
assert len(log) == 0
|
||||
log.append(AuditEvent(action="a", outcome=AuditOutcome.SUCCESS))
|
||||
log.append(AuditEvent(action="b", outcome=AuditOutcome.SUCCESS))
|
||||
assert len(log) == 2
|
||||
|
||||
|
||||
def test_log_file_is_created_if_missing(tmp_path: Path) -> None:
|
||||
p = tmp_path / "subdir" / "audit.jsonl"
|
||||
log = AuditLog(p)
|
||||
assert p.exists()
|
||||
assert len(log) == 0
|
||||
|
||||
|
||||
def test_corrupt_line_raises(tmp_path: Path) -> None:
|
||||
p = tmp_path / "audit.jsonl"
|
||||
p.write_text(
|
||||
'{"action":"good","outcome":"success","timestamp":"2026-05-18T00:00:00Z"}\n'
|
||||
"this is not json\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
log = AuditLog(p)
|
||||
with pytest.raises(ValueError, match="corrupt audit line"):
|
||||
log.read_all()
|
||||
|
||||
|
||||
def test_dual_auth_event_carries_secondary_user(tmp_path: Path) -> None:
|
||||
log = AuditLog(tmp_path / "a.jsonl")
|
||||
ev = AuditEvent(
|
||||
action="flash_firmware",
|
||||
outcome=AuditOutcome.SUCCESS,
|
||||
user_id="engineer_1",
|
||||
role="engineer",
|
||||
secondary_user_id="super_admin_alvaro",
|
||||
target="COM7:esp32-dev",
|
||||
)
|
||||
log.append(ev)
|
||||
read = log.read_all()
|
||||
assert read[0].secondary_user_id == "super_admin_alvaro"
|
||||
assert read[0].target == "COM7:esp32-dev"
|
||||
|
||||
|
||||
def test_extra_payload_round_trips(tmp_path: Path) -> None:
|
||||
log = AuditLog(tmp_path / "a.jsonl")
|
||||
ev = AuditEvent(
|
||||
action="mode_change",
|
||||
outcome=AuditOutcome.SUCCESS,
|
||||
extra={"from": "STANDBY", "to": "HEADING_HOLD", "via": "modbus"},
|
||||
)
|
||||
log.append(ev)
|
||||
read = log.read_all()
|
||||
assert read[0].extra == {"from": "STANDBY", "to": "HEADING_HOLD", "via": "modbus"}
|
||||
|
||||
|
||||
def test_blank_lines_are_skipped(tmp_path: Path) -> None:
|
||||
p = tmp_path / "a.jsonl"
|
||||
p.write_text(
|
||||
'{"action":"a","outcome":"success","timestamp":"2026-05-18T00:00:00Z"}\n'
|
||||
"\n"
|
||||
'{"action":"b","outcome":"denied","timestamp":"2026-05-18T00:00:01Z"}\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
log = AuditLog(p)
|
||||
events = log.read_all()
|
||||
assert len(events) == 2
|
||||
@@ -0,0 +1,154 @@
|
||||
"""Tests for the 4-role RBAC system."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from arautopilot.core.rbac import (
|
||||
Capability,
|
||||
PermissionError,
|
||||
Role,
|
||||
capabilities_of,
|
||||
has,
|
||||
require,
|
||||
requires_dual_auth,
|
||||
)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Capability matrix
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_super_admin_has_every_capability() -> None:
|
||||
super_admin_caps = capabilities_of(Role.SUPER_ADMIN)
|
||||
assert super_admin_caps == frozenset(Capability)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("cap", [
|
||||
Capability.FLASH_FIRMWARE,
|
||||
Capability.BUILD_FIRMWARE,
|
||||
Capability.EDIT_FIRMWARE_SOURCE,
|
||||
Capability.EDIT_COMMISSIONING,
|
||||
Capability.EDIT_OPERATIONAL,
|
||||
Capability.ENGAGE_PILOT,
|
||||
Capability.READ_TELEMETRY,
|
||||
Capability.ACK_ALARMS,
|
||||
Capability.VIEW_AUDIT_LOG_FULL,
|
||||
Capability.VIEW_AUDIT_LOG_VESSEL,
|
||||
])
|
||||
def test_engineer_can(cap: Capability) -> None:
|
||||
assert has(Role.ENGINEER, cap)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("cap", [
|
||||
Capability.EDIT_PYTHON_PROJECT,
|
||||
Capability.EDIT_BASE_GAINS,
|
||||
Capability.MANAGE_USERS,
|
||||
])
|
||||
def test_engineer_cannot(cap: Capability) -> None:
|
||||
assert not has(Role.ENGINEER, cap)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("cap", [
|
||||
Capability.EDIT_OPERATIONAL,
|
||||
Capability.MANAGE_USERS,
|
||||
Capability.ENGAGE_PILOT,
|
||||
Capability.READ_TELEMETRY,
|
||||
Capability.ACK_ALARMS,
|
||||
Capability.VIEW_AUDIT_LOG_VESSEL,
|
||||
])
|
||||
def test_owner_can(cap: Capability) -> None:
|
||||
assert has(Role.OWNER, cap)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("cap", [
|
||||
Capability.EDIT_PYTHON_PROJECT,
|
||||
Capability.EDIT_FIRMWARE_SOURCE,
|
||||
Capability.FLASH_FIRMWARE,
|
||||
Capability.BUILD_FIRMWARE,
|
||||
Capability.EDIT_BASE_GAINS,
|
||||
Capability.EDIT_COMMISSIONING,
|
||||
Capability.VIEW_AUDIT_LOG_FULL,
|
||||
])
|
||||
def test_owner_cannot(cap: Capability) -> None:
|
||||
assert not has(Role.OWNER, cap)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("cap", [
|
||||
Capability.ENGAGE_PILOT,
|
||||
Capability.READ_TELEMETRY,
|
||||
Capability.ACK_ALARMS,
|
||||
])
|
||||
def test_user_can(cap: Capability) -> None:
|
||||
assert has(Role.USER, cap)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("cap", [
|
||||
Capability.EDIT_PYTHON_PROJECT,
|
||||
Capability.EDIT_FIRMWARE_SOURCE,
|
||||
Capability.FLASH_FIRMWARE,
|
||||
Capability.BUILD_FIRMWARE,
|
||||
Capability.EDIT_BASE_GAINS,
|
||||
Capability.EDIT_COMMISSIONING,
|
||||
Capability.EDIT_OPERATIONAL,
|
||||
Capability.MANAGE_USERS,
|
||||
Capability.VIEW_AUDIT_LOG_FULL,
|
||||
Capability.VIEW_AUDIT_LOG_VESSEL,
|
||||
])
|
||||
def test_user_cannot(cap: Capability) -> None:
|
||||
assert not has(Role.USER, cap)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Dual-auth policy
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_engineer_flashing_requires_dual_auth() -> None:
|
||||
assert requires_dual_auth(Role.ENGINEER, Capability.FLASH_FIRMWARE)
|
||||
|
||||
|
||||
def test_super_admin_flashing_is_single_factor() -> None:
|
||||
assert not requires_dual_auth(Role.SUPER_ADMIN, Capability.FLASH_FIRMWARE)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role", [Role.SUPER_ADMIN, Role.ENGINEER, Role.OWNER, Role.USER])
|
||||
@pytest.mark.parametrize("cap", [
|
||||
Capability.ENGAGE_PILOT,
|
||||
Capability.READ_TELEMETRY,
|
||||
Capability.EDIT_OPERATIONAL,
|
||||
])
|
||||
def test_non_flash_actions_never_need_dual_auth(role: Role, cap: Capability) -> None:
|
||||
assert not requires_dual_auth(role, cap)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# require() helper
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_require_passes_when_granted() -> None:
|
||||
require(Role.USER, Capability.ENGAGE_PILOT) # no raise
|
||||
|
||||
|
||||
def test_require_raises_on_denial() -> None:
|
||||
with pytest.raises(PermissionError):
|
||||
require(Role.USER, Capability.EDIT_BASE_GAINS)
|
||||
|
||||
|
||||
def test_no_privilege_escalation_via_unknown_capability() -> None:
|
||||
# Make sure that Owner / User can never grow capabilities by some
|
||||
# accidental code path -- we just snapshot the matrix here.
|
||||
assert capabilities_of(Role.USER) < capabilities_of(Role.OWNER)
|
||||
assert capabilities_of(Role.OWNER) < capabilities_of(Role.SUPER_ADMIN)
|
||||
assert capabilities_of(Role.ENGINEER) < capabilities_of(Role.SUPER_ADMIN)
|
||||
|
||||
|
||||
def test_engineer_and_owner_capabilities_overlap_but_neither_subsumes_other() -> None:
|
||||
eng = capabilities_of(Role.ENGINEER)
|
||||
own = capabilities_of(Role.OWNER)
|
||||
# Engineer can flash; Owner can manage users -- neither set is a subset
|
||||
# of the other.
|
||||
assert Capability.FLASH_FIRMWARE in eng and Capability.FLASH_FIRMWARE not in own
|
||||
assert Capability.MANAGE_USERS in own and Capability.MANAGE_USERS not in eng
|
||||
@@ -0,0 +1,146 @@
|
||||
"""Tests for ``arautopilot.studio.session.Session``."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from arautopilot.core.audit import AuditLog, AuditOutcome
|
||||
from arautopilot.core.rbac import Capability, Role
|
||||
from arautopilot.core.user import User
|
||||
from arautopilot.core.user_store import UserStore
|
||||
from arautopilot.studio.session import Session, SessionHolder
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store_and_audit(tmp_path: Path) -> tuple[UserStore, AuditLog]:
|
||||
store = UserStore(tmp_path / "users.json")
|
||||
audit = AuditLog(tmp_path / "audit.jsonl")
|
||||
return store, audit
|
||||
|
||||
|
||||
def _sa_session(store: UserStore, audit: AuditLog) -> Session:
|
||||
u = User.create(display_name="SA", role=Role.SUPER_ADMIN, pin="0001")
|
||||
store.add(u)
|
||||
return Session(user=u, store=store, audit=audit)
|
||||
|
||||
|
||||
def _eng_session(store: UserStore, audit: AuditLog) -> Session:
|
||||
u = User.create(display_name="Eng", role=Role.ENGINEER, pin="0002")
|
||||
store.add(u)
|
||||
return Session(user=u, store=store, audit=audit)
|
||||
|
||||
|
||||
def test_session_can_for_super_admin(store_and_audit: tuple[UserStore, AuditLog]) -> None:
|
||||
store, audit = store_and_audit
|
||||
s = _sa_session(store, audit)
|
||||
assert s.can(Capability.FLASH_FIRMWARE)
|
||||
assert s.can(Capability.EDIT_BASE_GAINS)
|
||||
assert s.can(Capability.MANAGE_USERS)
|
||||
|
||||
|
||||
def test_session_check_records_audit_on_grant(
|
||||
store_and_audit: tuple[UserStore, AuditLog]
|
||||
) -> None:
|
||||
store, audit = store_and_audit
|
||||
s = _sa_session(store, audit)
|
||||
assert s.check(Capability.FLASH_FIRMWARE, target="COM7") is True
|
||||
events = audit.read_all()
|
||||
assert len(events) == 1
|
||||
assert events[0].outcome is AuditOutcome.SUCCESS
|
||||
assert events[0].action == "check:flash_firmware"
|
||||
assert events[0].target == "COM7"
|
||||
|
||||
|
||||
def test_session_check_records_audit_on_denial(
|
||||
store_and_audit: tuple[UserStore, AuditLog]
|
||||
) -> None:
|
||||
store, audit = store_and_audit
|
||||
u = User.create(display_name="Crew", role=Role.USER, pin="4444")
|
||||
store.add(u)
|
||||
s = Session(user=u, store=store, audit=audit)
|
||||
assert s.check(Capability.FLASH_FIRMWARE) is False
|
||||
events = audit.read_all()
|
||||
assert len(events) == 1
|
||||
assert events[0].outcome is AuditOutcome.DENIED
|
||||
assert "lacks" in events[0].reason
|
||||
|
||||
|
||||
def test_engineer_needs_dual_auth_for_flash(
|
||||
store_and_audit: tuple[UserStore, AuditLog]
|
||||
) -> None:
|
||||
store, audit = store_and_audit
|
||||
s = _eng_session(store, audit)
|
||||
assert s.needs_dual_auth(Capability.FLASH_FIRMWARE)
|
||||
assert not s.needs_dual_auth(Capability.BUILD_FIRMWARE)
|
||||
|
||||
|
||||
def test_verify_super_admin_pin_succeeds_with_right_pin(
|
||||
store_and_audit: tuple[UserStore, AuditLog]
|
||||
) -> None:
|
||||
store, audit = store_and_audit
|
||||
sa = User.create(display_name="SA", role=Role.SUPER_ADMIN, pin="0001")
|
||||
store.add(sa)
|
||||
eng = User.create(display_name="Eng", role=Role.ENGINEER, pin="0002")
|
||||
store.add(eng)
|
||||
s = Session(user=eng, store=store, audit=audit)
|
||||
matched = s.verify_super_admin_pin("0001")
|
||||
assert matched is not None
|
||||
assert matched.user_id == sa.user_id
|
||||
|
||||
|
||||
def test_verify_super_admin_pin_fails_with_wrong_pin(
|
||||
store_and_audit: tuple[UserStore, AuditLog]
|
||||
) -> None:
|
||||
store, audit = store_and_audit
|
||||
store.add(User.create(display_name="SA", role=Role.SUPER_ADMIN, pin="0001"))
|
||||
eng = User.create(display_name="Eng", role=Role.ENGINEER, pin="0002")
|
||||
store.add(eng)
|
||||
s = Session(user=eng, store=store, audit=audit)
|
||||
assert s.verify_super_admin_pin("9999") is None
|
||||
|
||||
|
||||
def test_dual_auth_grant_records_secondary_user(
|
||||
store_and_audit: tuple[UserStore, AuditLog]
|
||||
) -> None:
|
||||
store, audit = store_and_audit
|
||||
sa = User.create(display_name="SA", role=Role.SUPER_ADMIN, pin="0001")
|
||||
store.add(sa)
|
||||
eng = User.create(display_name="Eng", role=Role.ENGINEER, pin="0002")
|
||||
store.add(eng)
|
||||
s = Session(user=eng, store=store, audit=audit)
|
||||
s.log_dual_auth_grant(Capability.FLASH_FIRMWARE, sa,
|
||||
target="COM7:esp32-dev",
|
||||
extra={"variant": "esp32-dev"})
|
||||
events = audit.read_all()
|
||||
assert events[0].secondary_user_id == sa.user_id
|
||||
assert events[0].outcome is AuditOutcome.SUCCESS
|
||||
assert events[0].target == "COM7:esp32-dev"
|
||||
assert events[0].extra == {"variant": "esp32-dev"}
|
||||
|
||||
|
||||
def test_session_holder_set_and_require(
|
||||
store_and_audit: tuple[UserStore, AuditLog]
|
||||
) -> None:
|
||||
store, audit = store_and_audit
|
||||
s = _sa_session(store, audit)
|
||||
SessionHolder.set(s)
|
||||
assert SessionHolder.current() is s
|
||||
assert SessionHolder.require() is s
|
||||
SessionHolder.set(None)
|
||||
assert SessionHolder.current() is None
|
||||
with pytest.raises(RuntimeError):
|
||||
SessionHolder.require()
|
||||
|
||||
|
||||
def test_log_action_helper(
|
||||
store_and_audit: tuple[UserStore, AuditLog]
|
||||
) -> None:
|
||||
store, audit = store_and_audit
|
||||
s = _sa_session(store, audit)
|
||||
s.log_action("mode_change", outcome=AuditOutcome.SUCCESS,
|
||||
extra={"from": "STANDBY", "to": "HEADING_HOLD"})
|
||||
events = audit.read_all()
|
||||
assert events[0].action == "mode_change"
|
||||
assert events[0].extra["to"] == "HEADING_HOLD"
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Smoke tests for the Studio entry point.
|
||||
|
||||
These tests verify that:
|
||||
|
||||
- The Studio modules import cleanly without a display server (no Qt
|
||||
classes are instantiated at module level).
|
||||
- ``arautopilot.studio.app.run(['--seed-demo', '--data-dir', tmp])``
|
||||
populates a fresh user store and exits 0 without launching a GUI.
|
||||
|
||||
GUI behaviour (login modal, main window, Flash Console widget rendering)
|
||||
is covered by manual smoke testing -- pytest is intentionally headless
|
||||
here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_studio_modules_import_cleanly() -> None:
|
||||
# Importing these must not require a display server.
|
||||
from arautopilot.studio import app # noqa: F401
|
||||
from arautopilot.studio import flash_console # noqa: F401
|
||||
from arautopilot.studio import login_window # noqa: F401
|
||||
from arautopilot.studio import main_window # noqa: F401
|
||||
from arautopilot.studio import session # noqa: F401
|
||||
|
||||
|
||||
def test_seed_demo_via_app_run(tmp_path: Path) -> None:
|
||||
from arautopilot.core.user_store import UserStore
|
||||
from arautopilot.studio.app import run
|
||||
|
||||
rc = run(["--seed-demo", "--data-dir", str(tmp_path)])
|
||||
assert rc == 0
|
||||
store = UserStore(tmp_path / "users.json")
|
||||
assert len(store) == 4
|
||||
sa = store.find_by_name("Alvaro")
|
||||
assert sa is not None
|
||||
assert sa.verify_pin("1111")
|
||||
|
||||
|
||||
def test_seed_demo_is_idempotent(tmp_path: Path) -> None:
|
||||
from arautopilot.core.user_store import UserStore
|
||||
from arautopilot.studio.app import run
|
||||
|
||||
assert run(["--seed-demo", "--data-dir", str(tmp_path)]) == 0
|
||||
assert run(["--seed-demo", "--data-dir", str(tmp_path)]) == 0
|
||||
store = UserStore(tmp_path / "users.json")
|
||||
assert len(store) == 4
|
||||
|
||||
|
||||
def test_flash_console_helper_finds_pio_or_returns_path() -> None:
|
||||
"""`_find_pio` must return a Path object even if the venv path doesn't
|
||||
exist (falls back to the bare 'pio' name)."""
|
||||
from arautopilot.studio.flash_console import _find_pio
|
||||
p = _find_pio()
|
||||
assert p is not None
|
||||
# We don't assert existence -- the function falls back to the bare name.
|
||||
from pathlib import Path as _P
|
||||
assert isinstance(p, _P)
|
||||
|
||||
|
||||
def test_list_serial_ports_is_safe_to_call() -> None:
|
||||
"""It must work whether or not pyserial finds ports."""
|
||||
from arautopilot.studio.flash_console import list_serial_ports
|
||||
result = list_serial_ports()
|
||||
assert isinstance(result, list)
|
||||
for entry in result:
|
||||
assert isinstance(entry, tuple)
|
||||
assert len(entry) == 2
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Tests for ``arautopilot.core.user``."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from arautopilot.core.rbac import Role
|
||||
from arautopilot.core.user import User
|
||||
|
||||
|
||||
def test_create_user_with_valid_pin() -> None:
|
||||
u = User.create(display_name="Test Engineer", role=Role.ENGINEER, pin="1234")
|
||||
assert u.display_name == "Test Engineer"
|
||||
assert u.role is Role.ENGINEER
|
||||
assert u.pin_hash.startswith("pbkdf2_sha256$")
|
||||
assert u.active is True
|
||||
assert u.last_login_at is None
|
||||
|
||||
|
||||
def test_verify_correct_pin() -> None:
|
||||
u = User.create(display_name="X", role=Role.USER, pin="9876")
|
||||
assert u.verify_pin("9876") is True
|
||||
|
||||
|
||||
def test_verify_incorrect_pin() -> None:
|
||||
u = User.create(display_name="X", role=Role.USER, pin="9876")
|
||||
assert u.verify_pin("0000") is False
|
||||
assert u.verify_pin("987") is False # too short, treated as invalid
|
||||
assert u.verify_pin("98765") is False
|
||||
assert u.verify_pin("") is False
|
||||
|
||||
|
||||
def test_pin_hash_is_different_each_time_for_same_pin() -> None:
|
||||
"""Salting must make the hashes diverge even for identical PINs."""
|
||||
a = User.create(display_name="A", role=Role.USER, pin="1234")
|
||||
b = User.create(display_name="B", role=Role.USER, pin="1234")
|
||||
assert a.pin_hash != b.pin_hash
|
||||
# But both verify against the same PIN.
|
||||
assert a.verify_pin("1234")
|
||||
assert b.verify_pin("1234")
|
||||
|
||||
|
||||
def test_set_pin_returns_new_instance_with_new_hash() -> None:
|
||||
u = User.create(display_name="A", role=Role.USER, pin="1234")
|
||||
old_hash = u.pin_hash
|
||||
u2 = u.set_pin("5678")
|
||||
assert u2.pin_hash != old_hash
|
||||
assert u2.verify_pin("5678") is True
|
||||
assert u2.verify_pin("1234") is False
|
||||
|
||||
|
||||
def test_pin_must_be_numeric_4_to_8_digits() -> None:
|
||||
with pytest.raises(ValueError):
|
||||
User.create(display_name="A", role=Role.USER, pin="abc")
|
||||
with pytest.raises(ValueError):
|
||||
User.create(display_name="A", role=Role.USER, pin="12")
|
||||
with pytest.raises(ValueError):
|
||||
User.create(display_name="A", role=Role.USER, pin="123456789")
|
||||
with pytest.raises(ValueError):
|
||||
User.create(display_name="A", role=Role.USER, pin="12 34")
|
||||
|
||||
|
||||
def test_pin_hash_field_validator_rejects_garbage() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
User(display_name="A", role=Role.USER, pin_hash="not-a-real-hash")
|
||||
with pytest.raises(ValidationError):
|
||||
# Right shape but wrong scheme.
|
||||
User(display_name="A", role=Role.USER,
|
||||
pin_hash="md5$1000$xxxxxxxxxx$yyyyyyyyyy")
|
||||
|
||||
|
||||
def test_user_rejects_unknown_field() -> None:
|
||||
# Direct construction with unknown field should fail (extra="forbid").
|
||||
with pytest.raises(ValidationError):
|
||||
User(display_name="A", role=Role.USER, pin_hash="pbkdf2_sha256$200000$xx$yy",
|
||||
unknown="x") # type: ignore[call-arg]
|
||||
|
||||
|
||||
def test_touch_login_advances_timestamp() -> None:
|
||||
u = User.create(display_name="A", role=Role.USER, pin="1234")
|
||||
assert u.last_login_at is None
|
||||
u2 = u.touch_login()
|
||||
assert u2.last_login_at is not None
|
||||
assert u2.user_id == u.user_id
|
||||
|
||||
|
||||
def test_vessel_scoped_user() -> None:
|
||||
u = User.create(display_name="Captain", role=Role.OWNER, pin="4242",
|
||||
vessel_id="abc123")
|
||||
assert u.vessel_id == "abc123"
|
||||
|
||||
|
||||
def test_serialisation_round_trip() -> None:
|
||||
import json
|
||||
u = User.create(display_name="Test", role=Role.ENGINEER, pin="2468")
|
||||
data = u.model_dump(mode="json")
|
||||
text = json.dumps(data)
|
||||
rebuilt = User.model_validate(json.loads(text))
|
||||
assert rebuilt.display_name == u.display_name
|
||||
assert rebuilt.role == u.role
|
||||
assert rebuilt.pin_hash == u.pin_hash
|
||||
assert rebuilt.verify_pin("2468") is True
|
||||
@@ -0,0 +1,102 @@
|
||||
"""Tests for ``arautopilot.core.user_store``."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from arautopilot.core.rbac import Role
|
||||
from arautopilot.core.user import User
|
||||
from arautopilot.core.user_store import UserStore, seed_demo_users
|
||||
|
||||
|
||||
def test_empty_store_starts_empty(tmp_path: Path) -> None:
|
||||
s = UserStore(tmp_path / "users.json")
|
||||
assert len(s) == 0
|
||||
assert s.all_users() == []
|
||||
|
||||
|
||||
def test_add_and_get(tmp_path: Path) -> None:
|
||||
s = UserStore(tmp_path / "users.json")
|
||||
u = User.create(display_name="A", role=Role.USER, pin="1234")
|
||||
s.add(u)
|
||||
assert len(s) == 1
|
||||
assert s.get(u.user_id) is not None
|
||||
assert s.get(u.user_id).display_name == "A"
|
||||
|
||||
|
||||
def test_duplicate_user_id_rejected(tmp_path: Path) -> None:
|
||||
s = UserStore(tmp_path / "users.json")
|
||||
u = User.create(display_name="A", role=Role.USER, pin="1234")
|
||||
s.add(u)
|
||||
with pytest.raises(ValueError):
|
||||
s.add(u)
|
||||
|
||||
|
||||
def test_remove_unknown_raises(tmp_path: Path) -> None:
|
||||
s = UserStore(tmp_path / "users.json")
|
||||
with pytest.raises(KeyError):
|
||||
s.remove("nonexistent")
|
||||
|
||||
|
||||
def test_persistence_across_instances(tmp_path: Path) -> None:
|
||||
p = tmp_path / "users.json"
|
||||
s = UserStore(p)
|
||||
s.add(User.create(display_name="A", role=Role.USER, pin="1234"))
|
||||
s.add(User.create(display_name="B", role=Role.OWNER, pin="5678"))
|
||||
|
||||
s2 = UserStore(p)
|
||||
assert len(s2) == 2
|
||||
a = s2.find_by_name("A")
|
||||
b = s2.find_by_name("B")
|
||||
assert a is not None and a.verify_pin("1234")
|
||||
assert b is not None and b.verify_pin("5678")
|
||||
|
||||
|
||||
def test_by_role_filters_correctly(tmp_path: Path) -> None:
|
||||
s = UserStore(tmp_path / "users.json")
|
||||
s.add(User.create(display_name="SA", role=Role.SUPER_ADMIN, pin="0001"))
|
||||
s.add(User.create(display_name="Eng1", role=Role.ENGINEER, pin="0002"))
|
||||
s.add(User.create(display_name="Eng2", role=Role.ENGINEER, pin="0003"))
|
||||
s.add(User.create(display_name="Own", role=Role.OWNER, pin="0004"))
|
||||
assert len(s.by_role(Role.ENGINEER)) == 2
|
||||
assert len(s.by_role(Role.OWNER)) == 1
|
||||
assert len(s.by_role(Role.USER)) == 0
|
||||
|
||||
|
||||
def test_seed_demo_creates_one_of_each_role(tmp_path: Path) -> None:
|
||||
s = UserStore(tmp_path / "users.json")
|
||||
seed_demo_users(s)
|
||||
assert len(s) == 4
|
||||
assert s.find_by_name("Alvaro") is not None
|
||||
assert s.find_by_name("Alvaro").role is Role.SUPER_ADMIN
|
||||
assert s.find_by_name("Alvaro").verify_pin("1111")
|
||||
|
||||
|
||||
def test_seed_demo_idempotent(tmp_path: Path) -> None:
|
||||
s = UserStore(tmp_path / "users.json")
|
||||
seed_demo_users(s)
|
||||
seed_demo_users(s) # no-op
|
||||
assert len(s) == 4
|
||||
|
||||
|
||||
def test_replace_updates_existing(tmp_path: Path) -> None:
|
||||
s = UserStore(tmp_path / "users.json")
|
||||
u = User.create(display_name="A", role=Role.USER, pin="1234")
|
||||
s.add(u)
|
||||
u2 = u.set_pin("9999")
|
||||
s.replace(u2)
|
||||
assert len(s) == 1
|
||||
fetched = s.get(u.user_id)
|
||||
assert fetched is not None
|
||||
assert fetched.verify_pin("9999")
|
||||
assert not fetched.verify_pin("1234")
|
||||
|
||||
|
||||
def test_membership_test(tmp_path: Path) -> None:
|
||||
s = UserStore(tmp_path / "users.json")
|
||||
u = User.create(display_name="A", role=Role.USER, pin="1234")
|
||||
s.add(u)
|
||||
assert u.user_id in s
|
||||
assert "nope" not in s
|
||||
Reference in New Issue
Block a user