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.
237 lines
7.4 KiB
Python
237 lines
7.4 KiB
Python
"""Shared test fixtures.
|
|
|
|
Every test runs against the in-memory repository with fast Argon2 parameters.
|
|
No test reads a production configuration, touches PostgreSQL, or contains a
|
|
password literal: candidate passwords are generated at run time by
|
|
:func:`random_password`, so the repository contains no credential value, real
|
|
or otherwise.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import secrets
|
|
import string
|
|
from collections.abc import Iterator
|
|
from dataclasses import dataclass
|
|
from typing import Any
|
|
from uuid import UUID
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
from umbratrans_web.app import create_app
|
|
from umbratrans_web.config import Settings, suite_settings
|
|
from umbratrans_web.models import AccountView, Principal, RoleName
|
|
from umbratrans_web.repository import InMemoryAccountRepository
|
|
from umbratrans_web.service import AccountService, OperatorContext
|
|
|
|
ALPHABET = string.ascii_letters + string.digits + "-_.@"
|
|
|
|
|
|
def random_password(length: int = 24) -> str:
|
|
"""Return a random password for one test run.
|
|
|
|
Generated rather than literal so no fixture file, snapshot, or failure
|
|
message can ever carry credential material.
|
|
"""
|
|
|
|
return "".join(secrets.choice(ALPHABET) for _ in range(length))
|
|
|
|
|
|
@dataclass
|
|
class SeededAccount:
|
|
"""A test account plus the plaintext password used to create it."""
|
|
|
|
view: AccountView
|
|
password: str
|
|
user_id: UUID
|
|
username: str
|
|
|
|
|
|
class ApiHarness:
|
|
"""Small helper that keeps CSRF and session cookies consistent."""
|
|
|
|
def __init__(self, client: TestClient, settings: Settings) -> None:
|
|
self._client = client
|
|
self._settings = settings
|
|
|
|
@property
|
|
def client(self) -> TestClient:
|
|
return self._client
|
|
|
|
def csrf(self) -> str:
|
|
value = self._client.cookies.get(self._settings.csrf_cookie_name)
|
|
assert value, "no CSRF cookie has been issued yet"
|
|
return str(value)
|
|
|
|
def post(self, path: str, **kwargs: Any) -> Any:
|
|
headers = dict(kwargs.pop("headers", {}))
|
|
headers.setdefault(self._settings.csrf_header_name, self.csrf())
|
|
return self._client.post(path, headers=headers, **kwargs)
|
|
|
|
def patch(self, path: str, **kwargs: Any) -> Any:
|
|
headers = dict(kwargs.pop("headers", {}))
|
|
headers.setdefault(self._settings.csrf_header_name, self.csrf())
|
|
return self._client.patch(path, headers=headers, **kwargs)
|
|
|
|
def get(self, path: str, **kwargs: Any) -> Any:
|
|
"""Safe request. The CSRF header is accepted but not required here."""
|
|
|
|
headers = dict(kwargs.pop("headers", {}))
|
|
token = self._client.cookies.get(self._settings.csrf_cookie_name)
|
|
if token:
|
|
headers.setdefault(self._settings.csrf_header_name, str(token))
|
|
return self._client.get(path, headers=headers, **kwargs)
|
|
|
|
def login(self, username: str, password: str) -> Any:
|
|
return self._client.post("/api/auth/login", json={"username": username, "password": password})
|
|
|
|
def login_ok(self, account: SeededAccount) -> Any:
|
|
response = self.login(account.username, account.password)
|
|
assert response.status_code == 200, response.text
|
|
return response
|
|
|
|
def logout(self) -> None:
|
|
self._client.cookies.clear()
|
|
|
|
|
|
@pytest.fixture
|
|
def settings() -> Settings:
|
|
return suite_settings()
|
|
|
|
|
|
@pytest.fixture
|
|
def repository() -> InMemoryAccountRepository:
|
|
return InMemoryAccountRepository()
|
|
|
|
|
|
@pytest.fixture
|
|
def service(repository: InMemoryAccountRepository, settings: Settings) -> AccountService:
|
|
return AccountService(repository, settings)
|
|
|
|
|
|
@pytest.fixture
|
|
def app(repository: InMemoryAccountRepository, settings: Settings, service: AccountService) -> Any:
|
|
return create_app(settings=settings, repository=repository, service=service)
|
|
|
|
|
|
@pytest.fixture
|
|
def client(app: Any) -> Iterator[TestClient]:
|
|
with TestClient(app) as test_client:
|
|
yield test_client
|
|
|
|
|
|
@pytest.fixture
|
|
def api(client: TestClient, settings: Settings) -> ApiHarness:
|
|
return ApiHarness(client, settings)
|
|
|
|
|
|
def provision_local(
|
|
service: AccountService,
|
|
*,
|
|
username: str = "Operator.User",
|
|
password: str | None = None,
|
|
display_name: str = "Local Operator",
|
|
role: str = str(RoleName.PLATFORM_ADMIN),
|
|
organization_id: UUID | None = None,
|
|
) -> SeededAccount:
|
|
"""Create a local temporary-password account through the operator path."""
|
|
|
|
candidate = password or random_password()
|
|
view = service.create_account(
|
|
enrollment_mode="temporary_password",
|
|
username=username,
|
|
display_name=display_name,
|
|
role=role,
|
|
organization_id=organization_id,
|
|
password=candidate,
|
|
operator=OperatorContext(source="operator-cli"),
|
|
)
|
|
return SeededAccount(view=view, password=candidate, user_id=view.id, username=view.username)
|
|
|
|
|
|
def provision_oidc(
|
|
service: AccountService,
|
|
actor: Principal,
|
|
*,
|
|
username: str = "Oidc.User",
|
|
display_name: str = "OIDC User",
|
|
role: str = str(RoleName.MEMBER),
|
|
organization_id: UUID | None = None,
|
|
) -> SeededAccount:
|
|
"""Create an OIDC-enrollment account as an administrator."""
|
|
|
|
view = service.create_oidc_account(
|
|
username=username,
|
|
display_name=display_name,
|
|
role=role,
|
|
organization_id=organization_id,
|
|
actor=actor,
|
|
)
|
|
return SeededAccount(view=view, password="", user_id=view.id, username=view.username)
|
|
|
|
|
|
def activated_admin(
|
|
service: AccountService,
|
|
*,
|
|
username: str = "Umbraadmin",
|
|
role: str = str(RoleName.PLATFORM_ADMIN),
|
|
organization_id: UUID | None = None,
|
|
) -> SeededAccount:
|
|
"""Provision an admin and complete its first-login password change.
|
|
|
|
Returns the account with the *new* password, so tests can sign in over HTTP
|
|
with a session that is not restricted to the password-change route.
|
|
"""
|
|
|
|
account = provision_local(service, username=username, role=role, organization_id=organization_id)
|
|
result = service.authenticate(username=account.username, password=account.password)
|
|
principal = service.resolve_session(result.session.token)
|
|
new_password = random_password(28)
|
|
service.change_first_password(principal, current_password=account.password, new_password=new_password)
|
|
return SeededAccount(
|
|
view=service.self_view(account.user_id),
|
|
password=new_password,
|
|
user_id=account.user_id,
|
|
username=account.username,
|
|
)
|
|
|
|
|
|
def admin_principal(account: SeededAccount, *, organization_id: UUID | None = None) -> Principal:
|
|
"""Build a principal directly, for service tests that skip HTTP."""
|
|
|
|
return Principal(
|
|
user_id=account.user_id,
|
|
username=account.username,
|
|
role=RoleName(account.view.role),
|
|
session_id=secrets.token_hex(8),
|
|
organization_id=organization_id if organization_id is not None else account.view.organization_id,
|
|
)
|
|
|
|
|
|
def member_principal(account: SeededAccount, *, organization_id: UUID | None = None) -> Principal:
|
|
return Principal(
|
|
user_id=account.user_id,
|
|
username=account.username,
|
|
role=RoleName.MEMBER,
|
|
session_id=secrets.token_hex(8),
|
|
organization_id=organization_id if organization_id is not None else account.view.organization_id,
|
|
)
|
|
|
|
|
|
__all__ = [
|
|
"ALPHABET",
|
|
"ApiHarness",
|
|
"SeededAccount",
|
|
"activated_admin",
|
|
"admin_principal",
|
|
"client",
|
|
"member_principal",
|
|
"provision_local",
|
|
"provision_oidc",
|
|
"random_password",
|
|
"repository",
|
|
"service",
|
|
"settings",
|
|
]
|