Files
UmbraTrans/umbratrans_web/tests/test_repository.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

233 lines
9.0 KiB
Python

"""Repository tests: in-memory behaviour and the local JSON development store."""
from __future__ import annotations
import json
import os
import stat
from dataclasses import replace
from pathlib import Path
from types import SimpleNamespace
from uuid import uuid4
import pytest
from umbratrans_web.config import Environment, Settings
from umbratrans_web.dev_repository import JsonDevelopmentRepository
from umbratrans_web.errors import ConfigurationError, ConflictError, NotFoundError, RepositoryError
from umbratrans_web.models import AccountStatus
from umbratrans_web.repository import AccountRepository, InMemoryAccountRepository, build_repository
from umbratrans_web.service import AccountService
from .conftest import TEST_DSN, ensure_organization, provision_local, random_password
# ----------------------------------------------------------------------
# In-memory repository
# ----------------------------------------------------------------------
def test_in_memory_repository_satisfies_the_protocol() -> None:
assert isinstance(InMemoryAccountRepository(), AccountRepository)
def test_build_repository_selects_the_requested_kind() -> None:
assert isinstance(build_repository(Settings(environment=Environment.TEST)), InMemoryAccountRepository)
def test_build_repository_refuses_postgres_without_a_dsn() -> None:
"""No connection string must stop the build, not silently use a dev store."""
# Constructed in test mode, which skips the production DSN gate, so the
# repository boundary is the one refusing.
settings = Settings(environment=Environment.TEST, repository_kind="postgres")
with pytest.raises(RepositoryError) as error:
build_repository(settings)
assert error.value.code == "database_dsn_missing"
def test_duplicate_user_ids_and_usernames_are_rejected(
repository: InMemoryAccountRepository, service: AccountService
) -> None:
account = provision_local(service, username="Umbraadmin")
stored = repository.get_user(account.user_id)
assert stored is not None
with pytest.raises(ConflictError) as duplicate_id:
repository.create_user(stored)
assert duplicate_id.value.code == "account_exists"
with pytest.raises(ConflictError) as duplicate_name:
repository.create_user(replace(stored, id=uuid4()))
assert duplicate_name.value.code == "username_taken"
def test_saving_an_unknown_user_fails(repository: InMemoryAccountRepository, service: AccountService) -> None:
account = provision_local(service, username="Umbraadmin")
stored = repository.get_user(account.user_id)
assert stored is not None
with pytest.raises(NotFoundError):
repository.save_user(replace(stored, id=uuid4()))
def test_find_users_filters_and_paginates(repository: InMemoryAccountRepository, service: AccountService) -> None:
org = ensure_organization(service, name="Filter Org").id
elsewhere = ensure_organization(service, name="Other Org").id
for index in range(3):
provision_local(service, username=f"Member{index}", role="member", organization_id=org)
provision_local(service, username="Elsewhere", role="member", organization_id=elsewhere)
page, total = repository.find_users(organization_id=org, limit=2, offset=0)
assert total == 3
assert len(page) == 2
second_page, _ = repository.find_users(organization_id=org, limit=2, offset=2)
assert len(second_page) == 1
_, total_all = repository.find_users()
assert total_all == 4
_, active = repository.find_users(status=AccountStatus.ACTIVE)
assert active == 4
# Username lookup is case-insensitive in both directions.
assert repository.get_user_by_username("MEMBER0") is not None
assert repository.get_user_by_username("member0") is not None
assert repository.get_user_by_username("member0") == repository.get_user_by_username("Member0")
def test_audit_listing_is_newest_first(repository: InMemoryAccountRepository, service: AccountService) -> None:
account = provision_local(service, username="Umbraadmin")
with pytest.raises(Exception):
service.authenticate(username=account.username, password=random_password())
events, total = repository.list_audit_events(limit=50)
assert total == len(events) >= 1
timestamps = [event.occurred_at for event in events]
assert timestamps == sorted(timestamps, reverse=True)
# ----------------------------------------------------------------------
# JSON development repository
# ----------------------------------------------------------------------
def _dev_settings(path: Path) -> Settings:
return Settings(
environment=Environment.TEST,
repository_kind="json",
repository_path=path,
argon2_memory_cost_kib=8 * 1024,
argon2_time_cost=1,
argon2_parallelism=1,
)
def test_json_repository_writes_a_private_file(tmp_path: Path) -> None:
path = tmp_path / "state" / "dev-accounts.json"
repository = JsonDevelopmentRepository(_dev_settings(path))
service = AccountService(repository, repository.settings)
provision_local(service, username="Umbraadmin")
assert path.is_file()
assert stat.S_IMODE(path.stat().st_mode) == 0o600
assert stat.S_IMODE(path.parent.stat().st_mode) == 0o700
assert not list(path.parent.glob(".dev-repo-*"))
def test_json_repository_persists_hashes_across_restarts(tmp_path: Path) -> None:
path = tmp_path / "dev-accounts.json"
settings = _dev_settings(path)
first = AccountService(JsonDevelopmentRepository(settings), settings)
account = provision_local(first, username="Umbraadmin")
second = AccountService(JsonDevelopmentRepository(settings), settings)
stored = second.repository.get_credential(account.user_id)
assert stored is not None
assert stored.password_hash is not None
assert stored.must_change_password is True
# The account still authenticates from the persisted hash.
result = second.authenticate(username=account.username, password=account.password)
assert result.session.must_change_password is True
# The audit trail survived too.
events, total = second.repository.list_audit_events()
assert total >= 1
assert events
def test_json_file_contains_no_plaintext_password(tmp_path: Path) -> None:
path = tmp_path / "dev-accounts.json"
repository = JsonDevelopmentRepository(_dev_settings(path))
service = AccountService(repository, repository.settings)
account = provision_local(service, username="Umbraadmin")
raw = path.read_text(encoding="utf-8")
assert account.password not in raw
assert "$argon2id$" in raw
payload = json.loads(raw)
assert payload["schema_version"] == 1
assert payload["credentials"][0]["credential_type"] == "local_password"
assert payload["credentials"][0]["must_change_password"] is True
def test_json_repository_refuses_production_mode(tmp_path: Path) -> None:
impostor = SimpleNamespace(
is_production=True,
environment=Environment.PRODUCTION,
database_dsn=TEST_DSN,
repository_path=tmp_path / "dev-accounts.json",
)
with pytest.raises(ConfigurationError) as error:
JsonDevelopmentRepository(impostor) # type: ignore[arg-type]
assert error.value.code == "development_repository_in_production"
# The settings layer refuses the same combination.
with pytest.raises(ConfigurationError) as settings_error:
Settings(
environment=Environment.PRODUCTION,
database_dsn=TEST_DSN,
repository_kind="json",
cookie_secure=True,
repository_path=tmp_path / "dev-accounts.json",
)
assert settings_error.value.code == "production_repository_missing"
def test_json_repository_refuses_a_symlinked_file(tmp_path: Path) -> None:
target = tmp_path / "real.json"
target.write_text("{}", encoding="utf-8")
link = tmp_path / "dev-accounts.json"
os.symlink(target, link)
with pytest.raises(RepositoryError) as error:
JsonDevelopmentRepository(_dev_settings(link))
assert error.value.code == "symlink_refused"
def test_json_repository_rejects_an_unknown_schema_version(tmp_path: Path) -> None:
path = tmp_path / "dev-accounts.json"
path.write_text(json.dumps({"schema_version": 99}), encoding="utf-8")
with pytest.raises(RepositoryError) as error:
JsonDevelopmentRepository(_dev_settings(path))
assert error.value.code == "repository_schema_mismatch"
def test_json_repository_rejects_corrupt_content(tmp_path: Path) -> None:
path = tmp_path / "dev-accounts.json"
path.write_text("not json at all", encoding="utf-8")
with pytest.raises(RepositoryError) as error:
JsonDevelopmentRepository(_dev_settings(path))
assert error.value.code == "repository_corrupt"
def test_json_repository_tightens_loose_permissions(tmp_path: Path) -> None:
path = tmp_path / "dev-accounts.json"
repository = JsonDevelopmentRepository(_dev_settings(path))
provision_local(AccountService(repository, repository.settings), username="Umbraadmin")
os.chmod(path, 0o644)
repository.flush()
assert stat.S_IMODE(path.stat().st_mode) == 0o600