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