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.
304 lines
9.7 KiB
Python
304 lines
9.7 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 re
|
|
import secrets
|
|
import string
|
|
from collections.abc import Iterator
|
|
from dataclasses import dataclass
|
|
from typing import Any
|
|
from uuid import UUID, uuid4
|
|
|
|
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,
|
|
Organization,
|
|
OrganizationStatus,
|
|
Principal,
|
|
RoleName,
|
|
normalize_organization_name,
|
|
normalize_organization_slug,
|
|
)
|
|
from umbratrans_web.repository import InMemoryAccountRepository
|
|
from umbratrans_web.security import utc_now
|
|
from umbratrans_web.service import AccountService, OperatorContext
|
|
|
|
ALPHABET = string.ascii_letters + string.digits + "-_.@"
|
|
|
|
#: A syntactically valid connection string used only to satisfy the production
|
|
#: configuration gate. No test ever opens it: the PostgreSQL integration suite
|
|
#: reads UMBRATRANS_TEST_DSN and skips when it is unset.
|
|
TEST_DSN = "postgresql://app@db.internal/umbracontrol"
|
|
|
|
|
|
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))
|
|
|
|
|
|
def ensure_organization(
|
|
service: AccountService,
|
|
*,
|
|
name: str = "Test Organization",
|
|
slug: str | None = None,
|
|
status: OrganizationStatus = OrganizationStatus.ACTIVE,
|
|
) -> Organization:
|
|
"""Seed an organization directly in the repository.
|
|
|
|
Bypasses the service because creating a tenant requires a platform
|
|
administrator principal, and provisioning that first administrator is
|
|
exactly what these fixtures exist to set up. Service-level authorization
|
|
for organization writes is covered in ``test_service.py`` and
|
|
``test_api_uadmin.py``.
|
|
"""
|
|
|
|
now = utc_now()
|
|
organization = Organization(
|
|
id=uuid4(),
|
|
name=normalize_organization_name(name),
|
|
slug=normalize_organization_slug(slug) if slug is not None else normalize_organization_slug(name),
|
|
status=status,
|
|
created_at=now,
|
|
updated_at=now,
|
|
)
|
|
return service.repository.create_organization(organization) # type: ignore[attr-defined]
|
|
|
|
|
|
@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 random_email(seed: str) -> str:
|
|
"""Return a unique, syntactically valid address for one test account.
|
|
|
|
The control plane declares ``users.email`` NOT NULL UNIQUE, so email is a
|
|
required field. Generated per test run rather than written as a literal so
|
|
no fixture file carries a fixed identifier. The domain is fixed because a
|
|
username may contain dots that are not part of a domain name.
|
|
"""
|
|
|
|
local = re.sub(r"[^a-z0-9]+", "", seed.lower())[:40] or "user"
|
|
return f"{local}.{secrets.token_hex(4)}@example.test"
|
|
|
|
|
|
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,
|
|
email: str | None = None,
|
|
) -> SeededAccount:
|
|
"""Create a local temporary-password account through the operator path.
|
|
|
|
``email=None`` is passed through unchanged so a test can assert that a
|
|
missing address is refused; every other caller gets a generated one.
|
|
"""
|
|
|
|
candidate = password or random_password()
|
|
view = service.create_account(
|
|
enrollment_mode="temporary_password",
|
|
username=username,
|
|
display_name=display_name,
|
|
email=email if email is not None else random_email(username),
|
|
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,
|
|
email: str | None = None,
|
|
) -> SeededAccount:
|
|
"""Create an OIDC-enrollment account as an administrator."""
|
|
|
|
view = service.create_oidc_account(
|
|
username=username,
|
|
display_name=display_name,
|
|
email=email or random_email(username),
|
|
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",
|
|
"TEST_DSN",
|
|
"ApiHarness",
|
|
"SeededAccount",
|
|
"activated_admin",
|
|
"admin_principal",
|
|
"client",
|
|
"ensure_organization",
|
|
"member_principal",
|
|
"provision_local",
|
|
"provision_oidc",
|
|
"random_email",
|
|
"random_password",
|
|
"repository",
|
|
"service",
|
|
"settings",
|
|
]
|