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

430 lines
16 KiB
Python

"""HTTP authentication tests: health, login, first-password change, sessions."""
from __future__ import annotations
import pytest
from fastapi.testclient import TestClient
from umbratrans_web.app import create_app
from umbratrans_web.config import Environment, Settings
from umbratrans_web.errors import ConfigurationError
from umbratrans_web.repository import InMemoryAccountRepository
from umbratrans_web.service import AccountService
from .conftest import TEST_DSN, ApiHarness, activated_admin, provision_local, random_password
# ----------------------------------------------------------------------
# Health
# ----------------------------------------------------------------------
def test_health_is_public_and_discloses_no_secret(client: TestClient) -> None:
response = client.get("/api/health")
assert response.status_code == 200
body = response.json()
assert body["status"] == "ok"
assert body["service"] == "umbratrans-web"
assert body["environment"] == "test"
assert body["repository"] == "memory"
assert set(body) == {
"status",
"service",
"version",
"environment",
"repository",
"cookie_secure",
"csrf_required",
"time",
}
text = response.text.lower()
for forbidden in ("password", "token", "secret", "argon2", "postgres://", "dsn"):
assert forbidden not in text
def test_api_responses_are_not_cached_and_carry_a_request_id(client: TestClient) -> None:
response = client.get("/api/health")
assert response.headers["cache-control"] == "no-store"
assert response.headers["x-content-type-options"] == "nosniff"
assert response.headers["x-frame-options"] == "DENY"
assert response.headers["referrer-policy"] == "no-referrer"
assert response.headers["x-request-id"]
def test_request_id_is_echoed_when_supplied(client: TestClient) -> None:
response = client.get("/api/health", headers={"X-Request-Id": "trace-1234"})
assert response.headers["x-request-id"] == "trace-1234"
# ----------------------------------------------------------------------
# Login
# ----------------------------------------------------------------------
def test_login_sets_a_private_session_cookie_and_no_token_in_the_body(
api: ApiHarness, client: TestClient, settings: Settings
) -> None:
account = provision_local(_service_of(api))
response = api.login_ok(account)
assert response.status_code == 200
body = response.json()
assert body["must_change_password"] is True
assert body["user"]["username"] == account.username
assert body["csrf_token"] == api.csrf()
cookie_header = response.headers["set-cookie"]
assert f"{settings.session_cookie_name}=" in cookie_header
assert "HttpOnly" in cookie_header
assert "SameSite=lax" in cookie_header
assert "Path=/" in cookie_header
# Development only: the flag is configurable and must be secure in production.
assert "Secure" not in cookie_header
assert client.cookies[settings.session_cookie_name]
assert set(body) == {
"user",
"must_change_password",
"csrf_token",
"idle_expires_at",
"absolute_expires_at",
}
serialized = response.text
assert account.password not in serialized
assert "$argon2" not in serialized
assert "password_hash" not in serialized
def test_stored_session_token_is_never_the_cookie_value(
api: ApiHarness, client: TestClient, settings: Settings, service: AccountService
) -> None:
account = activated_admin(service)
api.login_ok(account)
token = client.cookies[settings.session_cookie_name]
stored = [session for session in service.repository.list_sessions(account.user_id, include_revoked=True)]
assert stored
assert all(session.token_hash != token for session in stored)
assert all(token not in session.token_hash for session in stored)
def test_login_failures_are_generic(api: ApiHarness, service: AccountService) -> None:
"""Unknown user, wrong password and suspended account are indistinguishable."""
account = provision_local(service, username="Umbraadmin")
suspended_account = provision_local(service, username="Suspended.User", role="member")
admin = activated_admin(service, username="Admin.Operator")
api.login_ok(admin)
api.patch(
f"/api/uadmin/accounts/{suspended_account.user_id}/status",
json={"status": "suspended", "reason": "policy review"},
)
api.logout()
unknown = api.login("Nobody.Here", random_password())
wrong = api.login(account.username, random_password())
suspended = api.login(suspended_account.username, suspended_account.password)
assert unknown.status_code == wrong.status_code == suspended.status_code == 401
assert unknown.json() == wrong.json() == suspended.json()
assert unknown.json()["error"]["code"] == "invalid_credentials"
# The audit trail keeps the real reason; the response does not.
events, _ = service.repository.list_audit_events(target_user_id=suspended_account.user_id)
reasons = {event.detail.get("reason") for event in events if event.action == "auth.login"}
assert "account_suspended" in reasons
def test_validation_errors_do_not_echo_the_submitted_password(client: TestClient) -> None:
# Longer than the accepted maximum, so the request is rejected by the
# schema. FastAPI's default error body would embed this value.
password = random_password(1200)
response = client.post("/api/auth/login", json={"username": "Umbraadmin", "password": password})
assert response.status_code == 422
assert response.json()["error"]["code"] == "invalid_request"
assert response.json()["error"]["fields"] == ["password"]
assert password not in response.text
assert "input" not in response.json()["error"]
def test_missing_credentials_are_rejected_without_detail_leak(client: TestClient) -> None:
response = client.post("/api/auth/login", json={})
assert response.status_code == 422
assert response.json()["error"]["fields"] == ["password", "username"]
assert "input" not in response.text
def test_login_rejects_unknown_fields(client: TestClient) -> None:
response = client.post(
"/api/auth/login",
json={"username": "Umbraadmin", "password": random_password(), "role": "platform_admin"},
)
assert response.status_code == 422
# ----------------------------------------------------------------------
# First-password change
# ----------------------------------------------------------------------
def test_must_change_session_cannot_reach_other_routes(
api: ApiHarness, client: TestClient, service: AccountService
) -> None:
account = provision_local(service, username="Umbraadmin")
api.login_ok(account)
me = client.get("/api/auth/me")
accounts = client.get("/api/uadmin/accounts")
assert me.status_code == 403
assert me.json()["error"]["code"] == "password_change_required"
assert accounts.status_code == 403
assert accounts.json()["error"]["code"] == "password_change_required"
def test_first_password_change_completes_the_flow(
api: ApiHarness, client: TestClient, settings: Settings, service: AccountService
) -> None:
account = provision_local(service, username="Umbraadmin")
api.login_ok(account)
old_cookie = client.cookies[settings.session_cookie_name]
new_password = random_password(28)
response = api.post(
"/api/auth/change-first-password",
json={"current_password": account.password, "new_password": new_password},
)
assert response.status_code == 200
assert response.json()["must_change_password"] is False
new_cookie = client.cookies[settings.session_cookie_name]
assert new_cookie != old_cookie
# The rotated session works, the old one is gone, and the temporary
# password no longer authenticates.
assert client.get("/api/auth/me").status_code == 200
api.logout()
assert api.login(account.username, account.password).status_code == 401
assert api.login(account.username, new_password).status_code == 200
def test_first_password_change_validates_the_temporary_password(api: ApiHarness, service: AccountService) -> None:
account = provision_local(service, username="Umbraadmin")
api.login_ok(account)
wrong = api.post(
"/api/auth/change-first-password",
json={"current_password": random_password(), "new_password": random_password(28)},
)
weak = api.post(
"/api/auth/change-first-password",
json={"current_password": account.password, "new_password": "short"},
)
assert wrong.status_code == 401
assert wrong.json()["error"]["code"] == "invalid_credentials"
assert weak.status_code == 422
assert weak.json()["error"]["code"] == "password_policy"
# Unmet rules are named; the candidate value is never echoed.
assert "min_length" in weak.json()["error"]["requirements"]
assert "short" not in weak.text
def test_first_password_change_requires_a_csrf_token(
api: ApiHarness, client: TestClient, service: AccountService
) -> None:
account = provision_local(service, username="Umbraadmin")
api.login_ok(account)
response = client.post(
"/api/auth/change-first-password",
json={"current_password": account.password, "new_password": random_password(28)},
)
assert response.status_code == 403
assert response.json()["error"]["code"] == "csrf_token_missing"
def test_csrf_token_mismatch_is_rejected(api: ApiHarness, client: TestClient, settings: Settings) -> None:
account = activated_admin(_service_of(api))
api.login_ok(account)
response = client.post(
"/api/auth/logout",
headers={settings.csrf_header_name: "not-the-right-token"},
)
assert response.status_code == 403
assert response.json()["error"]["code"] in {"csrf_token_missing", "csrf_token_invalid"}
# ----------------------------------------------------------------------
# Sessions
# ----------------------------------------------------------------------
def test_logout_revokes_the_session_and_clears_the_cookie(
api: ApiHarness, client: TestClient, settings: Settings, service: AccountService
) -> None:
account = activated_admin(service)
api.login_ok(account)
response = api.post("/api/auth/logout")
assert response.status_code == 200
assert response.json()["revoked_sessions"] == 1
assert "Max-Age=0" in response.headers["set-cookie"] or "max-age=0" in response.headers["set-cookie"]
assert client.get("/api/auth/me").status_code == 401
assert api.login(account.username, account.password).status_code == 200
def test_logout_everywhere_ends_all_sessions(
api: ApiHarness, client: TestClient, settings: Settings, service: AccountService
) -> None:
account = activated_admin(service)
api.login_ok(account)
first_cookie = client.cookies[settings.session_cookie_name]
# A second browser session for the same account.
with TestClient(client.app) as second:
second.post("/api/auth/login", json={"username": account.username, "password": account.password})
second_cookie = second.cookies[settings.session_cookie_name]
assert second_cookie != first_cookie
live_before = len(service.repository.list_sessions(account.user_id))
assert live_before >= 2
response = api.post("/api/auth/logout-everywhere")
assert response.status_code == 200
assert response.json()["revoked_sessions"] == live_before
assert second.get("/api/auth/me").status_code == 401
assert client.get("/api/auth/me").status_code == 401
assert service.repository.list_sessions(account.user_id) == []
def test_me_requires_a_session(client: TestClient) -> None:
response = client.get("/api/auth/me")
assert response.status_code == 401
assert response.json()["error"]["code"] == "authentication_required"
assert response.headers["x-umbratrans-reauthenticate"] == "1"
def test_me_returns_only_the_callers_own_identity(api: ApiHarness, service: AccountService) -> None:
account = activated_admin(service, username="Admin.One")
other = provision_local(service, username="Other.User", role="member")
api.login_ok(account)
response = api.client.get("/api/auth/me")
body = response.json()
assert body["user"]["id"] == str(account.user_id)
assert body["user"]["username"] == account.username
assert body["role"] == "platform_admin"
assert body["must_change_password"] is False
assert str(other.user_id) not in response.text
assert other.password not in response.text
assert "password_hash" not in response.text
def test_forged_session_cookie_is_rejected(client: TestClient, settings: Settings) -> None:
client.cookies.set(settings.session_cookie_name, random_password(40))
response = client.get("/api/auth/me")
assert response.status_code == 401
# ----------------------------------------------------------------------
# Cookie hardening
# ----------------------------------------------------------------------
def test_production_cookies_are_secure(settings: Settings) -> None:
production = Settings(
environment=Environment.PRODUCTION,
repository_kind="postgres",
database_dsn=TEST_DSN,
cookie_secure=True,
)
repository = InMemoryAccountRepository()
service = AccountService(repository, production)
app = create_app(settings=production, repository=repository, service=service)
account = activated_admin(service, username="Umbraadmin")
with TestClient(app) as client:
response = client.post("/api/auth/login", json={"username": account.username, "password": account.password})
cookie_header = response.headers["set-cookie"]
assert response.status_code == 200
assert "Secure" in cookie_header
assert "HttpOnly" in cookie_header
assert "SameSite=lax" in cookie_header
def test_production_refuses_insecure_configuration() -> None:
with pytest.raises(ConfigurationError) as error:
Settings(
environment=Environment.PRODUCTION,
database_dsn=TEST_DSN,
repository_kind="postgres",
cookie_secure=False,
)
assert error.value.code == "insecure_production_cookie"
with pytest.raises(ConfigurationError) as csrf_error:
Settings(
environment=Environment.PRODUCTION,
database_dsn=TEST_DSN,
repository_kind="postgres",
cookie_secure=True,
require_csrf=False,
)
assert csrf_error.value.code == "insecure_production_csrf"
with pytest.raises(ConfigurationError) as repository_error:
Settings(
environment=Environment.PRODUCTION,
database_dsn=TEST_DSN,
repository_kind="memory",
cookie_secure=True,
)
assert repository_error.value.code == "production_repository_missing"
def test_production_app_requires_an_explicit_repository() -> None:
production = Settings(
environment=Environment.PRODUCTION,
repository_kind="postgres",
database_dsn=TEST_DSN,
cookie_secure=True,
)
with pytest.raises(ConfigurationError) as error:
create_app(settings=production)
assert error.value.code == "production_repository_missing"
def test_wildcard_hosts_are_refused() -> None:
with pytest.raises(ConfigurationError) as error:
Settings(environment=Environment.TEST, allowed_hosts=("example.test", "*"))
assert error.value.code == "wildcard_host_forbidden"
def test_allowed_hosts_are_enforced_when_configured() -> None:
settings = Settings(
environment=Environment.TEST,
allowed_hosts=("umbratrans.bdmurphy.me",),
argon2_memory_cost_kib=8 * 1024,
argon2_time_cost=1,
argon2_parallelism=1,
)
app = create_app(settings=settings, repository=InMemoryAccountRepository())
with TestClient(app) as client:
assert client.get("/api/health", headers={"Host": "umbratrans.bdmurphy.me"}).status_code == 200
assert client.get("/api/health", headers={"Host": "attacker.example"}).status_code == 400
def _service_of(api: ApiHarness) -> AccountService:
return api.client.app.state.service