Files
UmbraTrans/umbratrans_web/tests/test_service.py
umbra audio 31fd8720ec Add the PostgreSQL adapter for the authoritative store
Implements AccountRepository against the control-plane schema plus the
phase-1 identity migration, and wires it in so production can select it.

The adapter is not a thin SQL wrapper, because the control plane does not
store the two things the application needs on the users row.

Role and organization come from user_roles. There is no role column and no
organization column on users; a role is assigned through user_roles with a
scope, and app.validate_user_role_scope() enforces that the role's scope
matches the assignment. Resolving one role per user is therefore a join, and
a user holding more than one active assignment is refused rather than resolved
to an arbitrary row. The organization on that assignment becomes the scope of
every administrative decision, so guessing would hand out the wrong tenant's
authority. Reconciling this with project-scoped membership stays an open
review question, recorded as such.

Email became a required field on account creation. users.email is NOT NULL
UNIQUE, so accepting a missing address would let account creation succeed
against the development repositories and then fail on the authoritative store.
It is now required in the service, the API schema, and the bootstrap command,
so it fails identically everywhere.

Audit identifiers now belong to the store. audit_events.id is a bigint
identity, so the adapter reports that value; the application correlation UUID
is preserved in the detail and never displaces the row key. AuditEvent.id is a
string for that reason, which removes a type the two stores disagreed about.

Three further fail-closed behaviours: a users.status of invited or locked is
refused rather than coerced onto active; a row with mfa_required is refused,
because this phase cannot satisfy a second factor and authenticating it anyway
would grant access to someone who was supposed to present one; and a missing
organization blocks access rather than granting it. save_user deliberately does
not write username or email, since both are identity keys with no reviewed
change path and no audit event behind them.

Every operation runs in one transaction on one pooled connection and publishes
the caller with SET LOCAL app.current_user_id, so a returned connection cannot
carry one request's identity into the next. That is inert until the reviewed RLS
policies exist; the explicit WHERE clauses enforce scope today. A statement
timeout bounds how long a pooled connection can be held.

Production fails closed at two independent gates: the settings layer refuses
without a DSN, and the repository boundary refuses to build a PostgreSQL store
without one. Verified by running both, including that an unreachable host
refuses to start rather than degrading.

The migration gains an index on audit_events (target_type, target_id), which
the administrator audit filter needs and nothing covered, and the review
questions now include the three things that must be checked before
provisioning: the roles rows and their scopes, any row with mfa_required set,
and any user holding more than one active assignment.

Verification, and its limit. 236 tests pass with 3 skipped. The adapter's row
mapping, role resolution, conflict mapping, and configuration are unit tested
and run everywhere. Its SQL, transactions, and pooled reuse are NOT executed:
this environment has no database credentials, because sudo to the postgres user
needs a password, there is no umbra database role, and there is no .pgpass. The
integration suite is written and selected by UMBRATRANS_TEST_DSN, and its
fixture refuses any DSN whose database name contains umbracontrol. A green run
here says nothing about whether the SQL is correct.
2026-09-26 20:28:07 -07:00

679 lines
29 KiB
Python

"""Service-level tests: enrollment, authentication, sessions, authorization."""
from __future__ import annotations
from dataclasses import fields, is_dataclass
from uuid import UUID, uuid4
import pytest
from umbratrans_web.errors import (
ConflictError,
EnrollmentRefusedError,
ForbiddenError,
InputError,
InvalidCredentialsError,
OperatorRefusalError,
PasswordChangeRequiredError,
PasswordPolicyError,
StateTransitionError,
)
from umbratrans_web.models import AccountStatus, CredentialType, Principal, RoleName
from umbratrans_web.service import AccountService, OperatorContext
from .conftest import (
admin_principal,
ensure_organization,
member_principal,
provision_local,
provision_oidc,
random_email,
random_password,
)
# ----------------------------------------------------------------------
# Enrollment
# ----------------------------------------------------------------------
def test_operator_enrollment_stores_only_an_argon2id_hash(repository: object, service: AccountService) -> None:
account = provision_local(service, username="Umbraadmin")
stored = repository.get_credential(account.user_id) # type: ignore[attr-defined]
assert stored is not None
assert stored.credential_type is CredentialType.LOCAL_PASSWORD
assert stored.password_hash is not None
assert stored.password_hash.startswith("$argon2id$")
assert account.password not in stored.password_hash
assert stored.must_change_password is True
assert account.view.credential.must_change_password is True
def test_repository_state_contains_no_plaintext_password(repository: object, service: AccountService) -> None:
account = provision_local(service, username="Umbraadmin")
repository_object = repository
blobs: list[str] = []
for attribute in vars(repository_object).values():
if isinstance(attribute, dict):
for value in attribute.values():
blobs.append(repr(value))
elif isinstance(attribute, list):
blobs.extend(repr(value) for value in attribute)
joined = "\n".join(blobs)
# Nothing the repository holds - including every repr - contains the
# plaintext, and the hash is hidden from repr as well.
assert account.password not in joined
assert "$argon2id$" not in joined
stored = repository.get_credential(account.user_id) # type: ignore[attr-defined]
assert stored is not None
assert stored.password_hash is not None
assert stored.password_hash.startswith("$argon2id$")
def test_credential_records_hide_hashes_in_repr(service: AccountService) -> None:
account = provision_local(service, username="Umbraadmin")
stored = service.repository.get_credential(account.user_id)
assert stored is not None
assert stored.password_hash not in repr(stored)
assert account.password not in repr(stored)
def test_username_is_unique_case_insensitively(service: AccountService) -> None:
provision_local(service, username="Umbraadmin")
with pytest.raises(ConflictError) as error:
provision_local(service, username="umbraadmin")
assert error.value.code == "username_taken"
def test_generic_create_account_path_requires_the_operator_context(service: AccountService) -> None:
"""The generic entry point stays operator-only for local passwords.
Administrator-driven enrollment has its own entry point,
:meth:`provision_admin_temporary_password`, which generates the secret
instead of accepting one.
"""
actor = admin_principal(provision_local(service, username="Umbraadmin"))
password = random_password()
with pytest.raises(EnrollmentRefusedError) as error:
service.create_account(
enrollment_mode="temporary_password",
username="No.Path",
display_name="No Path",
email=random_email("no-path"),
password=password,
actor=actor,
)
assert error.value.code == "operator_context_required"
assert service.repository.get_user_by_username("no.path") is None
def test_admin_enrollment_generates_a_one_time_password(service: AccountService) -> None:
actor = admin_principal(provision_local(service, username="Umbraadmin"))
issued = service.provision_admin_temporary_password(
username="Temp.User",
display_name="Temp User",
email=random_email("temp-user"),
actor=actor,
)
# Handed back once, and hidden from any accidental rendering or log line.
assert issued.temporary_password not in repr(issued)
assert issued.account.credential.must_change_password is True
assert issued.account.credential.credential_type is CredentialType.LOCAL_PASSWORD
stored = service.repository.get_credential(issued.account.id)
assert stored is not None
assert stored.password_hash is not None
assert stored.password_hash.startswith("$argon2id$")
assert issued.temporary_password not in stored.password_hash
# It authenticates once, and only far enough to be replaced.
first = service.authenticate(username="Temp.User", password=issued.temporary_password)
assert first.session.must_change_password is True
replacement = random_password(30)
service.change_first_password(
service.resolve_session(first.session.token),
current_password=issued.temporary_password,
new_password=replacement,
)
with pytest.raises(InvalidCredentialsError):
service.authenticate(username="Temp.User", password=issued.temporary_password)
assert service.authenticate(username="Temp.User", password=replacement).session.must_change_password is False
def test_admin_enrollment_requires_an_administrator(service: AccountService) -> None:
member = provision_local(service, username="Member.User", role="member")
with pytest.raises(ForbiddenError):
service.provision_admin_temporary_password(
username="Escalate.User",
display_name="Escalate",
email=random_email("escalate-user"),
actor=member_principal(member),
)
assert service.repository.get_user_by_username("escalate.user") is None
def test_temporary_password_enrollment_requires_a_password(service: AccountService) -> None:
with pytest.raises(InputError) as error:
service.create_account(
enrollment_mode="temporary_password",
username="No.Password",
display_name="No Password",
email=random_email("no-password"),
operator=OperatorContext(source="operator-cli"),
)
assert error.value.code == "password_required"
def test_account_creation_requires_an_email(service: AccountService) -> None:
"""users.email is NOT NULL in the control plane, so the rule is in the domain.
Enforcing it here rather than in the store means a missing address fails the
same way against the development repositories and the authoritative store.
"""
actor = admin_principal(provision_local(service, username="Umbraadmin"))
with pytest.raises(InputError) as error:
service.create_oidc_account(
username="No.Email",
display_name="No Email",
email=None, # type: ignore[arg-type]
actor=actor,
)
assert error.value.code == "email_required"
with pytest.raises(InputError) as operator_error:
service.create_account(
enrollment_mode="temporary_password",
username="Also.No.Email",
display_name="Also No Email",
email=None, # type: ignore[arg-type]
password=random_password(28),
operator=OperatorContext(source="operator-cli"),
)
assert operator_error.value.code == "email_required"
def test_a_malformed_email_is_rejected(service: AccountService) -> None:
with pytest.raises(InputError) as error:
provision_local(service, username="Bad.Email", email="not-an-address")
assert error.value.code == "invalid_email"
def test_oidc_enrollment_creates_no_password_material(repository: object, service: AccountService) -> None:
actor = admin_principal(provision_local(service, username="Umbraadmin"))
account = provision_oidc(service, actor, username="Oidc.User")
stored = repository.get_credential(account.user_id) # type: ignore[attr-defined]
assert stored is not None
assert stored.credential_type is CredentialType.OIDC
assert stored.password_hash is None
assert stored.must_change_password is False
assert account.view.credential.credential_type is CredentialType.OIDC
def test_oidc_account_cannot_authenticate_with_a_local_password(service: AccountService) -> None:
actor = admin_principal(provision_local(service, username="Umbraadmin"))
account = provision_oidc(service, actor, username="Oidc.User")
with pytest.raises(InvalidCredentialsError):
service.authenticate(username=account.username, password=random_password())
def test_only_declared_operator_sources_are_accepted() -> None:
with pytest.raises(OperatorRefusalError) as error:
OperatorContext(source="http-post")
assert error.value.code == "unknown_operator_source"
# The HTTP layer never builds this object at all; see test_api_uadmin.
assert OperatorContext(source="operator-cli").interactive is True
# ----------------------------------------------------------------------
# Authentication
# ----------------------------------------------------------------------
def test_authentication_issues_a_session_without_exposing_the_token(service: AccountService) -> None:
account = provision_local(service, username="Umbraadmin")
result = service.authenticate(username=account.username, password=account.password)
assert result.session.token
assert result.session.must_change_password is True
principal = service.resolve_session(result.session.token)
assert principal.user_id == account.user_id
assert principal.must_change_password is True
def test_stored_session_token_is_a_hash_not_the_token(repository: object, service: AccountService) -> None:
account = provision_local(service, username="Umbraadmin")
result = service.authenticate(username=account.username, password=account.password)
stored = repository.get_session(result.session.session_id) # type: ignore[attr-defined]
assert stored is not None
assert stored.token_hash != result.session.token
assert result.session.token not in stored.token_hash
assert result.session.token not in repr(stored)
def test_unknown_user_and_wrong_password_are_indistinguishable(repository: object, service: AccountService) -> None:
account = provision_local(service, username="Umbraadmin")
with pytest.raises(InvalidCredentialsError) as unknown:
service.authenticate(username="Nobody.Here", password=random_password())
with pytest.raises(InvalidCredentialsError) as wrong:
service.authenticate(username=account.username, password=random_password())
assert unknown.value.code == wrong.value.code == "invalid_credentials"
assert unknown.value.message == wrong.value.message
assert unknown.value.message == InvalidCredentialsError().message
def test_login_failures_are_audited_without_the_username_value(repository: object, service: AccountService) -> None:
account = provision_local(service, username="Umbraadmin")
with pytest.raises(InvalidCredentialsError):
service.authenticate(username=account.username, password=random_password())
events, _ = repository.list_audit_events(target_user_id=account.user_id) # type: ignore[attr-defined]
failures = [event for event in events if event.action == "auth.login" and event.outcome == "failure"]
assert failures
assert failures[0].detail.get("reason") == "invalid_password"
assert "password" not in failures[0].detail
def test_repeated_failures_lock_the_credential(service: AccountService, settings: object) -> None:
account = provision_local(service, username="Umbraadmin")
for _ in range(settings.max_failed_login_attempts): # type: ignore[attr-defined]
with pytest.raises(InvalidCredentialsError):
service.authenticate(username=account.username, password=random_password())
stored = service.repository.get_credential(account.user_id)
assert stored is not None
assert stored.failed_login_count >= settings.max_failed_login_attempts # type: ignore[attr-defined]
assert stored.is_locked(service._now()) # type: ignore[attr-defined]
# Even the correct password is refused while locked, with the same error.
with pytest.raises(InvalidCredentialsError):
service.authenticate(username=account.username, password=account.password)
events, _ = service.repository.list_audit_events(target_user_id=account.user_id)
assert any(event.detail.get("lock_state") == "locked" for event in events)
def test_successful_login_clears_the_failure_counter(service: AccountService) -> None:
account = provision_local(service, username="Umbraadmin")
with pytest.raises(InvalidCredentialsError):
service.authenticate(username=account.username, password=random_password())
service.authenticate(username=account.username, password=account.password)
stored = service.repository.get_credential(account.user_id)
assert stored is not None
assert stored.failed_login_count == 0
assert stored.last_login_at is not None
# ----------------------------------------------------------------------
# First-login password change
# ----------------------------------------------------------------------
def test_first_login_change_rotates_the_password_and_revokes_old_sessions(service: AccountService) -> None:
account = provision_local(service, username="Umbraadmin")
first = service.authenticate(username=account.username, password=account.password)
second = service.authenticate(username=account.username, password=account.password)
principal = service.resolve_session(first.session.token)
new_password = random_password(28)
result = service.change_first_password(principal, current_password=account.password, new_password=new_password)
assert result.session.token != first.session.token
assert result.session.must_change_password is False
# The previous sessions, including the one used for the change, are gone.
with pytest.raises(InvalidCredentialsError):
service.resolve_session(first.session.token)
with pytest.raises(InvalidCredentialsError):
service.resolve_session(second.session.token)
# The temporary password no longer works.
with pytest.raises(InvalidCredentialsError):
service.authenticate(username=account.username, password=account.password)
# The new password does, and no longer forces a change.
reopened = service.authenticate(username=account.username, password=new_password)
assert reopened.session.must_change_password is False
assert service.resolve_session(result.session.token).must_change_password is False
def test_first_login_change_requires_the_temporary_password(service: AccountService) -> None:
account = provision_local(service, username="Umbraadmin")
result = service.authenticate(username=account.username, password=account.password)
principal = service.resolve_session(result.session.token)
with pytest.raises(InvalidCredentialsError):
service.change_first_password(principal, current_password=random_password(), new_password=random_password(28))
def test_first_login_change_rejects_password_reuse_and_weak_values(service: AccountService) -> None:
account = provision_local(service, username="Umbraadmin")
result = service.authenticate(username=account.username, password=account.password)
principal = service.resolve_session(result.session.token)
with pytest.raises(PasswordPolicyError) as reuse:
service.change_first_password(principal, current_password=account.password, new_password=account.password)
assert reuse.value.unmet_requirements == ("must_differ_from_current",)
with pytest.raises(PasswordPolicyError):
service.change_first_password(principal, current_password=account.password, new_password="short")
# ----------------------------------------------------------------------
# Session lifecycle
# ----------------------------------------------------------------------
def test_logout_revokes_only_the_current_session(service: AccountService) -> None:
account = provision_local(service, username="Umbraadmin")
first = service.authenticate(username=account.username, password=account.password)
second = service.authenticate(username=account.username, password=account.password)
principal = service.resolve_session(first.session.token)
service.logout(principal)
with pytest.raises(InvalidCredentialsError):
service.resolve_session(first.session.token)
assert service.resolve_session(second.session.token).user_id == account.user_id
def test_logout_everywhere_revokes_all_sessions(service: AccountService) -> None:
account = provision_local(service, username="Umbraadmin")
tokens = [
service.authenticate(username=account.username, password=account.password).session.token for _ in range(3)
]
principal = service.resolve_session(tokens[0])
revoked = service.logout_everywhere(principal)
assert revoked == 3
for token in tokens:
with pytest.raises(InvalidCredentialsError):
service.resolve_session(token)
def test_expired_sessions_are_rejected_and_revoked(service: AccountService, settings: object) -> None:
account = provision_local(service, username="Umbraadmin")
result = service.authenticate(username=account.username, password=account.password)
stored = service.repository.get_session(result.session.session_id)
assert stored is not None
from dataclasses import replace
expired = replace(stored, idle_deadline_at=service._now()) # type: ignore[attr-defined]
service.repository.save_session(expired)
with pytest.raises(InvalidCredentialsError):
service.resolve_session(result.session.token)
assert service.repository.get_session(result.session.session_id).revoked_reason == "expired" # type: ignore[union-attr]
def test_unknown_and_forged_tokens_are_rejected(service: AccountService) -> None:
for token in ("", "not-a-token", random_password(40)):
with pytest.raises(InvalidCredentialsError):
service.resolve_session(token)
# ----------------------------------------------------------------------
# Account lifecycle and authorization
# ----------------------------------------------------------------------
def test_suspension_revokes_sessions_and_blocks_new_logins(repository: object, service: AccountService) -> None:
actor = admin_principal(provision_local(service, username="Umbraadmin"))
member = provision_local(service, username="Member.User", role=str(RoleName.MEMBER))
member_result = service.authenticate(username=member.username, password=member.password)
assert service.resolve_session(member_result.session.token).user_id == member.user_id
view, revoked = service.set_account_status(actor, member.user_id, "suspended", reason="policy review")
assert view.status is AccountStatus.SUSPENDED
assert revoked == 1
with pytest.raises(InvalidCredentialsError):
service.resolve_session(member_result.session.token)
with pytest.raises(InvalidCredentialsError):
service.authenticate(username=member.username, password=member.password)
def test_reactivation_restores_access(service: AccountService) -> None:
admin = provision_local(service, username="Umbraadmin")
actor = admin_principal(admin)
member = provision_local(service, username="Member.User", role=str(RoleName.MEMBER))
service.set_account_status(actor, member.user_id, "suspended", reason="pause")
view, _ = service.set_account_status(actor, member.user_id, "active")
assert view.status is AccountStatus.ACTIVE
result = service.authenticate(username=member.username, password=member.password)
assert service.resolve_session(result.session.token).user_id == member.user_id
def test_administrative_session_revocation(service: AccountService) -> None:
admin = provision_local(service, username="Umbraadmin")
actor = admin_principal(admin)
member = provision_local(service, username="Member.User", role=str(RoleName.MEMBER))
result = service.authenticate(username=member.username, password=member.password)
assert service.revoke_sessions(actor, member.user_id) == 1
with pytest.raises(InvalidCredentialsError):
service.resolve_session(result.session.token)
def test_last_platform_admin_cannot_be_suspended(service: AccountService) -> None:
admin = provision_local(service, username="Umbraadmin")
actor = admin_principal(admin)
with pytest.raises(ConflictError) as error:
service.set_account_status(actor, admin.user_id, "suspended")
assert error.value.code == "last_platform_admin"
def test_status_transitions_are_enforced(service: AccountService) -> None:
admin = provision_local(service, username="Umbraadmin")
actor = admin_principal(admin)
member = provision_local(service, username="Member.User", role=str(RoleName.MEMBER))
with pytest.raises(StateTransitionError):
service.set_account_status(actor, member.user_id, "active")
service.set_account_status(actor, member.user_id, "deactivated")
with pytest.raises(StateTransitionError):
service.set_account_status(actor, member.user_id, "deactivated")
def test_members_cannot_use_administrator_operations(service: AccountService) -> None:
admin = provision_local(service, username="Umbraadmin")
member = provision_local(service, username="Member.User", role=str(RoleName.MEMBER))
actor = member_principal(member)
with pytest.raises(ForbiddenError):
service.list_accounts(actor)
with pytest.raises(ForbiddenError):
service.get_account(actor, admin.user_id)
with pytest.raises(ForbiddenError):
service.set_account_status(actor, admin.user_id, "suspended")
with pytest.raises(ForbiddenError):
service.revoke_sessions(actor, admin.user_id)
with pytest.raises(ForbiddenError):
service.list_audit_events(actor)
def test_organization_admin_cannot_cross_organizations(service: AccountService) -> None:
org_a = ensure_organization(service, name="Org A").id
org_b = ensure_organization(service, name="Org B").id
org_admin_account = provision_local(
service, username="Org.Admin", role=str(RoleName.ORGANIZATION_ADMIN), organization_id=org_a
)
other = provision_local(service, username="Other.User", role=str(RoleName.MEMBER), organization_id=org_b)
same_org = provision_local(service, username="Same.User", role=str(RoleName.MEMBER), organization_id=org_a)
actor = admin_principal(org_admin_account, organization_id=org_a)
visible, total = service.list_accounts(actor)
assert total == 2
assert {view.id for view in visible} == {org_admin_account.user_id, same_org.user_id}
with pytest.raises(ForbiddenError):
service.get_account(actor, other.user_id)
with pytest.raises(ForbiddenError):
service.set_account_status(actor, other.user_id, "suspended")
with pytest.raises(ForbiddenError):
service.revoke_sessions(actor, other.user_id)
def test_organization_admin_cannot_manage_a_platform_admin(service: AccountService) -> None:
org = ensure_organization(service, name="Scoped Org").id
admin = provision_local(service, username="Umbraadmin")
org_admin_account = provision_local(
service, username="Org.Admin", role=str(RoleName.ORGANIZATION_ADMIN), organization_id=org
)
actor = admin_principal(org_admin_account, organization_id=org)
with pytest.raises(ForbiddenError) as error:
service.get_account(actor, admin.user_id)
assert error.value.code == "platform_admin_only"
def test_organization_admin_cannot_promote_to_platform_admin(service: AccountService) -> None:
org = ensure_organization(service, name="Scoped Org").id
org_admin_account = provision_local(
service, username="Org.Admin", role=str(RoleName.ORGANIZATION_ADMIN), organization_id=org
)
actor = admin_principal(org_admin_account, organization_id=org)
with pytest.raises(ForbiddenError):
service.create_oidc_account(
username="Escalated.User",
display_name="Escalated",
email=random_email("escalated-user"),
role=str(RoleName.PLATFORM_ADMIN),
organization_id=org,
actor=actor,
)
def test_password_change_principal_cannot_use_admin_operations(service: AccountService) -> None:
admin = provision_local(service, username="Umbraadmin")
result = service.authenticate(username=admin.username, password=admin.password)
principal = service.resolve_session(result.session.token)
assert principal.must_change_password is True
with pytest.raises(PasswordChangeRequiredError):
service.list_accounts(principal)
with pytest.raises(PasswordChangeRequiredError):
service.create_oidc_account(
username="Blocked.User",
display_name="Blocked",
email=random_email("blocked-user"),
actor=principal,
)
def test_audit_events_are_sanitized_and_scoped(service: AccountService) -> None:
admin = provision_local(service, username="Umbraadmin")
actor = admin_principal(admin)
member = provision_local(service, username="Member.User", role=str(RoleName.MEMBER))
service.set_account_status(actor, member.user_id, "suspended", reason=f"password={random_password()}")
events, total = service.list_audit_events(actor)
assert total >= 1
statuses = [event for event in events if event.action == "account.status_changed"]
assert statuses
for event in events:
for key in event.detail:
assert key in {
"credential_type",
"enrollment_mode",
"lock_state",
"must_change_password",
"organization_id",
"previous_status",
"reason",
"revoked_sessions",
"role",
"source",
"status",
"username",
}
assert "password" not in event.detail
assert "token" not in event.detail
# The audit view is scoped: a non-admin sees nothing.
with pytest.raises(ForbiddenError):
service.list_audit_events(member_principal(member))
def test_audit_detail_never_contains_a_password_value(service: AccountService) -> None:
account = provision_local(service, username="Umbraadmin")
with pytest.raises(InvalidCredentialsError):
service.authenticate(username=account.username, password=random_password())
events, _ = service.repository.list_audit_events(target_user_id=account.user_id)
for event in events:
for value in event.detail.values():
assert account.password not in value
assert "$argon2" not in value
# ----------------------------------------------------------------------
# Model invariants
# ----------------------------------------------------------------------
def test_oidc_credential_cannot_store_a_hash() -> None:
from umbratrans_web.models import CredentialRecord
with pytest.raises(InputError):
CredentialRecord(
id=uuid4(),
user_id=uuid4(),
credential_type=CredentialType.OIDC,
password_hash="$argon2id$fake",
)
with pytest.raises(InputError):
CredentialRecord(
id=uuid4(),
user_id=uuid4(),
credential_type=CredentialType.LOCAL_PASSWORD,
password_hash=None,
)
def test_account_view_is_a_plain_dataclass_without_hash_fields() -> None:
from umbratrans_web.models import CredentialSummary
names = {item.name for item in fields(CredentialSummary)}
assert is_dataclass(CredentialSummary)
assert "password_hash" not in names
assert names == {
"credential_type",
"must_change_password",
"failed_login_count",
"locked",
"last_login_at",
"password_changed_at",
}
def test_username_and_display_name_validation() -> None:
from umbratrans_web.errors import InputError
from umbratrans_web.models import normalize_display_name, normalize_email, normalize_username, username_key
assert normalize_username(" Umbra.admin-1 ") == "Umbra.admin-1"
assert username_key("UmbraAdmin") == "umbraadmin"
for bad in ("", "ab", "with space", "with/slash", "x" * 65):
with pytest.raises(InputError):
normalize_username(bad)
assert normalize_email(" User@Example.COM ") == "user@example.com"
with pytest.raises(InputError):
normalize_email("not-an-email")
assert normalize_display_name(" Umbra Admin ") == "Umbra Admin"
def test_principal_organization_is_never_taken_from_the_browser() -> None:
"""The principal type has no field a browser could populate directly."""
principal = Principal(
user_id=UUID(int=1),
username="Umbraadmin",
role=RoleName.PLATFORM_ADMIN,
session_id=UUID(int=2),
organization_id=None,
)
assert not hasattr(principal, "request")
assert principal.is_admin is True