Unit (`tests/unit/test_user_schemas.py`): - 4 tests on `UserCreate` (happy path, password min length, email validation, invalid type). - 2 tests on `UserUpdate` (all-optional, password validation when set). - 3 tests on `EngagementMemberCreate` (default `"member"`, explicit role, max-length 40). - 4 tests on `PageQuery` (defaults, offset arithmetic, page_size cap, page lower bound). Integration (`tests/integration/test_user_mgmt_e2e.py`, marked `integration`): - The critical MA6-in-practice flow: rt_lead creates rt_operator, assigns to engagement A, the operator signs in, lists engagements and sees only A, `GET /engagements/B` returns 404 (anti-leak), `GET /engagements/B/members` returns 404 too, `/engagements/A/members` is reachable, `GET /users` is forbidden for the operator. - `USER_MANAGE` gate: anonymous → 401, operator session → 403, lead session → 200. - 409 `email_taken` on duplicate `POST /users`. - `/audit/log` is lead-only, paginates with `page_size`, filters by `?action=`. - Disabling a user blocks subsequent logins (same uniform `invalid_credentials` envelope as for bad passwords — no enumeration leak of "this account was disabled"). 74 unit tests pass (61 sprint 1 + 13 sprint 2); integration tests run on the testcontainers Postgres fixture in CI.
95 lines
3.1 KiB
Python
95 lines
3.1 KiB
Python
"""User/member DTO validation (no DB)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from uuid import uuid4
|
|
|
|
import pytest
|
|
from pydantic import ValidationError
|
|
|
|
from mimic.db.types import UserType
|
|
from mimic.schemas import EngagementMemberCreate, UserCreate, UserUpdate
|
|
from mimic.schemas.pagination import MAX_PAGE_SIZE, PageQuery
|
|
|
|
|
|
class TestUserCreate:
|
|
def test_minimal_valid_payload(self) -> None:
|
|
u = UserCreate.model_validate(
|
|
{
|
|
"email": "alice@example.org",
|
|
"password": "longenough",
|
|
"type": "rt_operator",
|
|
}
|
|
)
|
|
assert u.email == "alice@example.org"
|
|
assert u.type is UserType.RT_OPERATOR
|
|
assert u.display_name is None
|
|
|
|
def test_password_min_length(self) -> None:
|
|
with pytest.raises(ValidationError):
|
|
UserCreate.model_validate(
|
|
{"email": "a@b.c", "password": "short", "type": "rt_operator"}
|
|
)
|
|
|
|
def test_invalid_email_rejected(self) -> None:
|
|
with pytest.raises(ValidationError):
|
|
UserCreate.model_validate(
|
|
{"email": "not-an-email", "password": "longenough", "type": "rt_operator"}
|
|
)
|
|
|
|
def test_invalid_type_rejected(self) -> None:
|
|
with pytest.raises(ValidationError):
|
|
UserCreate.model_validate(
|
|
{"email": "a@b.c", "password": "longenough", "type": "not-a-role"}
|
|
)
|
|
|
|
|
|
class TestUserUpdate:
|
|
def test_all_optional(self) -> None:
|
|
u = UserUpdate.model_validate({})
|
|
assert u.display_name is None
|
|
assert u.password is None
|
|
assert u.type is None
|
|
|
|
def test_password_validates_when_provided(self) -> None:
|
|
with pytest.raises(ValidationError):
|
|
UserUpdate.model_validate({"password": "tiny"})
|
|
|
|
|
|
class TestEngagementMemberCreate:
|
|
def test_default_role(self) -> None:
|
|
member = EngagementMemberCreate.model_validate({"user_id": str(uuid4())})
|
|
assert member.role == "member"
|
|
|
|
def test_explicit_role(self) -> None:
|
|
member = EngagementMemberCreate.model_validate(
|
|
{"user_id": str(uuid4()), "role": "lead-on-mission"}
|
|
)
|
|
assert member.role == "lead-on-mission"
|
|
|
|
def test_role_max_length(self) -> None:
|
|
with pytest.raises(ValidationError):
|
|
EngagementMemberCreate.model_validate({"user_id": str(uuid4()), "role": "x" * 41})
|
|
|
|
|
|
class TestPageQuery:
|
|
def test_defaults(self) -> None:
|
|
page = PageQuery.model_validate({})
|
|
assert page.page == 1
|
|
assert page.page_size == 50
|
|
assert page.offset == 0
|
|
assert page.limit == 50
|
|
|
|
def test_offset_arithmetic(self) -> None:
|
|
page = PageQuery.model_validate({"page": 4, "page_size": 25})
|
|
assert page.offset == 75
|
|
assert page.limit == 25
|
|
|
|
def test_page_size_clamped(self) -> None:
|
|
with pytest.raises(ValidationError):
|
|
PageQuery.model_validate({"page_size": MAX_PAGE_SIZE + 1})
|
|
|
|
def test_page_lower_bound(self) -> None:
|
|
with pytest.raises(ValidationError):
|
|
PageQuery.model_validate({"page": 0})
|