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:
2026-05-18 18:04:27 -04:00
parent 295efa2d83
commit 13a2867ef6
18 changed files with 2307 additions and 14 deletions
+108
View File
@@ -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
+154
View File
@@ -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
+146
View File
@@ -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"
+70
View File
@@ -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
+103
View File
@@ -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
+102
View File
@@ -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