Implements the account and user-management system behind the new web UI against development repositories only. Nothing here connects to, reads, or modifies the production umbracontrol database, and no account is created in any approved environment. Account service and API: - Local temporary-password and OIDC enrollment, separated by type. - Argon2id credentials, must-change enforcement, server-side sessions with hashed tokens, idle and absolute deadlines, rotation and revocation. - Administrator account management: list, get, create, suspend, reactivate, deactivate, revoke sessions, org-scoped audit view. - Hidden-prompt operator command for the first administrator, refusing production mode and refusing to run without a TTY. Temporary passwords are never accepted from a client. The request schema has no password field and refuses unknown fields; the administrator path generates the one-time password in process, hashes it immediately, and returns it once in the creation response. The account can reach no route, including /api/auth/me, until it replaces that password, after which the one-time value no longer authenticates. Security posture: fail-closed configuration, Secure cookies in production, double-submit CSRF, constant-time token comparison, generic authentication errors, audit detail allow-listing, and logger-tree secret redaction. Reviewed but unapplied: migrations/0001_phase1_identity.sql adds users.username, a separate user_credentials table, and additive session columns, with a rollback and open review questions. The PostgreSQL adapter is deliberately absent, so the application refuses to start in production rather than run against an unreviewed store. Verification: 169 tests pass (148 new, 21 pre-existing); ruff check, ruff format, compileall, and make web-check are clean.
226 lines
8.6 KiB
Python
226 lines
8.6 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 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_the_absent_postgres_adapter() -> None:
|
|
settings = Settings(environment=Environment.TEST, repository_kind="postgres")
|
|
with pytest.raises(RepositoryError) as error:
|
|
build_repository(settings)
|
|
assert error.value.code == "postgres_adapter_unavailable"
|
|
|
|
|
|
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 = uuid4()
|
|
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=uuid4())
|
|
|
|
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,
|
|
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,
|
|
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
|