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.
549 lines
21 KiB
Python
549 lines
21 KiB
Python
"""HTTP administrator tests: authorization, enrollment, lifecycle, audit."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from uuid import UUID, uuid4
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
from umbratrans_web.service import AccountService
|
|
|
|
from .conftest import (
|
|
ApiHarness,
|
|
activated_admin,
|
|
ensure_organization,
|
|
provision_local,
|
|
random_email,
|
|
random_password,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def admin(api: ApiHarness, service: AccountService) -> object:
|
|
"""An activated platform administrator, signed in over HTTP."""
|
|
|
|
account = activated_admin(service, username="Umbraadmin")
|
|
api.login_ok(account)
|
|
return account
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# Authorization
|
|
# ----------------------------------------------------------------------
|
|
|
|
|
|
def test_admin_routes_require_a_session(client: TestClient) -> None:
|
|
for method, path in [
|
|
("get", "/api/uadmin/accounts"),
|
|
("get", "/api/uadmin/audit"),
|
|
("get", f"/api/uadmin/accounts/{uuid4()}"),
|
|
]:
|
|
response = getattr(client, method)(path)
|
|
assert response.status_code == 401, path
|
|
assert response.json()["error"]["code"] == "authentication_required"
|
|
|
|
for method, path in [
|
|
("post", "/api/uadmin/accounts"),
|
|
("post", f"/api/uadmin/accounts/{uuid4()}/revoke-sessions"),
|
|
]:
|
|
response = getattr(client, method)(path, json={})
|
|
# Rejected by CSRF first, then by authentication; either way, never 2xx.
|
|
assert response.status_code in {401, 403}, path
|
|
assert response.json()["error"]["code"] in {
|
|
"authentication_required",
|
|
"csrf_token_missing",
|
|
"csrf_token_invalid",
|
|
}
|
|
|
|
|
|
def test_members_are_forbidden(api: ApiHarness, service: AccountService) -> None:
|
|
member = provision_local(service, username="Member.User", role="member")
|
|
activated_admin(service, username="Admin.Helper")
|
|
api.login_ok(member) # the temporary password is all this session can do
|
|
|
|
# Complete the change so the session is usable, then retry.
|
|
new_password = random_password(28)
|
|
response = api.post(
|
|
"/api/auth/change-first-password",
|
|
json={"current_password": member.password, "new_password": new_password},
|
|
)
|
|
assert response.status_code == 200
|
|
|
|
for method, path, payload in [
|
|
("get", "/api/uadmin/accounts", None),
|
|
("get", "/api/uadmin/audit", None),
|
|
("get", f"/api/uadmin/accounts/{member.user_id}", None),
|
|
("post", "/api/uadmin/accounts", {"username": "New.User", "display_name": "New User"}),
|
|
("patch", f"/api/uadmin/accounts/{member.user_id}/status", {"status": "suspended"}),
|
|
("post", f"/api/uadmin/accounts/{member.user_id}/revoke-sessions", None),
|
|
]:
|
|
call = getattr(api, method)(path, **({"json": payload} if payload is not None else {}))
|
|
assert call.status_code == 403, f"{method} {path} -> {call.status_code}"
|
|
assert call.json()["error"]["code"] == "forbidden"
|
|
|
|
|
|
def test_temporary_password_accounts_cannot_reach_admin_routes(api: ApiHarness, service: AccountService) -> None:
|
|
account = provision_local(service, username="Umbraadmin", role="platform_admin")
|
|
api.login_ok(account)
|
|
response = api.get("/api/uadmin/accounts")
|
|
assert response.status_code == 403
|
|
assert response.json()["error"]["code"] == "password_change_required"
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# Account creation
|
|
# ----------------------------------------------------------------------
|
|
|
|
|
|
def test_create_oidc_account(api: ApiHarness, admin: object, service: AccountService) -> None:
|
|
response = api.post(
|
|
"/api/uadmin/accounts",
|
|
json={
|
|
"username": "Oidc.User",
|
|
"display_name": "OIDC User",
|
|
"enrollment_mode": "oidc",
|
|
"role": "member",
|
|
"email": "Oidc.User@Example.COM",
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 201
|
|
body = response.json()
|
|
assert body["temporary_password"] is None
|
|
account = body["account"]
|
|
assert account["username"] == "Oidc.User"
|
|
assert account["email"] == "oidc.user@example.com"
|
|
assert account["status"] == "active"
|
|
assert account["credential"] == {
|
|
"credential_type": "oidc",
|
|
"must_change_password": False,
|
|
"failed_login_count": 0,
|
|
"locked": False,
|
|
"last_login_at": None,
|
|
"password_changed_at": None,
|
|
}
|
|
assert "password_hash" not in response.text
|
|
|
|
stored = service.repository.get_credential(UUID(account["id"]))
|
|
assert stored is not None
|
|
assert stored.password_hash is None
|
|
|
|
|
|
def test_create_account_refuses_a_submitted_password_in_either_mode(api: ApiHarness, admin: object) -> None:
|
|
password = random_password(28)
|
|
for mode in ("oidc", "temporary_password"):
|
|
response = api.post(
|
|
"/api/uadmin/accounts",
|
|
json={
|
|
"username": f"Sneaky.User.{mode}",
|
|
"display_name": "Sneaky User",
|
|
"enrollment_mode": mode,
|
|
"password": password,
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 422
|
|
assert response.json()["error"]["code"] == "invalid_request"
|
|
assert password not in response.text
|
|
|
|
|
|
def test_admin_provisions_a_temporary_password_account(
|
|
api: ApiHarness,
|
|
admin: object,
|
|
service: AccountService,
|
|
) -> None:
|
|
response = api.post(
|
|
"/api/uadmin/accounts",
|
|
json={
|
|
"username": "Temp.User",
|
|
"display_name": "Temp User",
|
|
"enrollment_mode": "temporary_password",
|
|
"role": "member",
|
|
"email": "Temp.User@Example.com",
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 201
|
|
body = response.json()
|
|
account = body["account"]
|
|
temporary_password = body["temporary_password"]
|
|
|
|
# The one-time password is returned once, by this endpoint only.
|
|
assert isinstance(temporary_password, str)
|
|
assert len(temporary_password) >= 14
|
|
assert any(character.isdigit() for character in temporary_password)
|
|
assert any(not character.isalnum() for character in temporary_password)
|
|
assert account["credential"]["credential_type"] == "local_password"
|
|
assert account["credential"]["must_change_password"] is True
|
|
assert account["status"] == "active"
|
|
assert "$argon2" not in response.text
|
|
|
|
# It is never stored, logged, or audited in the clear.
|
|
stored = service.repository.get_credential(UUID(account["id"]))
|
|
assert stored is not None
|
|
assert stored.password_hash is not None
|
|
assert stored.password_hash.startswith("$argon2id$")
|
|
assert temporary_password not in stored.password_hash
|
|
assert temporary_password not in repr(stored)
|
|
assert temporary_password not in json.dumps(
|
|
[event.detail for event in service.repository.list_audit_events()[0]],
|
|
default=str,
|
|
)
|
|
# No other endpoint will ever repeat it.
|
|
assert temporary_password not in api.get(f"/api/uadmin/accounts/{account['id']}").text
|
|
assert temporary_password not in api.get("/api/uadmin/accounts").text
|
|
assert temporary_password not in api.get("/api/uadmin/audit").text
|
|
|
|
|
|
def test_generated_temporary_password_must_be_replaced_on_first_login(
|
|
api: ApiHarness,
|
|
admin: object,
|
|
) -> None:
|
|
created = api.post(
|
|
"/api/uadmin/accounts",
|
|
json={
|
|
"username": "First.Login",
|
|
"display_name": "First Login",
|
|
"email": random_email("first-login"),
|
|
"enrollment_mode": "temporary_password",
|
|
},
|
|
)
|
|
assert created.status_code == 201
|
|
username = created.json()["account"]["username"]
|
|
temporary_password = created.json()["temporary_password"]
|
|
|
|
api.logout()
|
|
login = api.login(username, temporary_password)
|
|
assert login.status_code == 200
|
|
assert login.json()["must_change_password"] is True
|
|
|
|
# The temporary session can do nothing except replace the password: every
|
|
# other route, including /api/auth/me, is refused until the change happens.
|
|
for path in ("/api/uadmin/accounts", "/api/auth/me"):
|
|
blocked = api.get(path)
|
|
assert blocked.status_code == 403, path
|
|
assert blocked.json()["error"]["code"] == "password_change_required", path
|
|
|
|
new_password = random_password(30)
|
|
changed = api.post(
|
|
"/api/auth/change-first-password",
|
|
json={"current_password": temporary_password, "new_password": new_password},
|
|
)
|
|
assert changed.status_code == 200
|
|
assert changed.json()["must_change_password"] is False
|
|
|
|
# The one-time password is spent: it never works again.
|
|
api.logout()
|
|
assert api.login(username, temporary_password).status_code == 401
|
|
assert api.login(username, new_password).status_code == 200
|
|
|
|
|
|
def test_generated_temporary_passwords_are_unique_per_account(
|
|
api: ApiHarness,
|
|
admin: object,
|
|
) -> None:
|
|
issued = {
|
|
api.post(
|
|
"/api/uadmin/accounts",
|
|
json={
|
|
"username": f"Bulk.User{index}",
|
|
"display_name": f"Bulk User {index}",
|
|
"email": random_email(f"bulk-{index}"),
|
|
"enrollment_mode": "temporary_password",
|
|
},
|
|
).json()["temporary_password"]
|
|
for index in range(5)
|
|
}
|
|
assert len(issued) == 5
|
|
|
|
|
|
def test_organization_admin_cannot_provision_outside_its_scope(api: ApiHarness, service: AccountService) -> None:
|
|
org_a = ensure_organization(service, name="Org A").id
|
|
org_b = ensure_organization(service, name="Org B").id
|
|
activated_admin(service, username="Umbraadmin")
|
|
org_admin = activated_admin(service, username="Org.Admin", role="organization_admin", organization_id=org_a)
|
|
api.login_ok(org_admin)
|
|
|
|
outside = api.post(
|
|
"/api/uadmin/accounts",
|
|
json={
|
|
"username": "Outside.Temp",
|
|
"display_name": "Outside Temp",
|
|
"email": random_email("outside-temp"),
|
|
"enrollment_mode": "temporary_password",
|
|
"organization_id": str(org_b),
|
|
},
|
|
)
|
|
escalate = api.post(
|
|
"/api/uadmin/accounts",
|
|
json={
|
|
"username": "Escalate.Temp",
|
|
"display_name": "Escalate Temp",
|
|
"email": random_email("escalate-temp"),
|
|
"enrollment_mode": "temporary_password",
|
|
"role": "platform_admin",
|
|
},
|
|
)
|
|
|
|
assert outside.status_code == 403
|
|
assert escalate.status_code == 403
|
|
# Nothing was created by either attempt.
|
|
assert api.get("/api/uadmin/accounts").json()["total"] == 1
|
|
|
|
|
|
def test_create_account_validates_input(api: ApiHarness, admin: object) -> None:
|
|
duplicate = api.post(
|
|
"/api/uadmin/accounts",
|
|
json={
|
|
"username": "Umbraadmin",
|
|
"display_name": "Duplicate",
|
|
"email": random_email("dup"),
|
|
"enrollment_mode": "oidc",
|
|
},
|
|
)
|
|
invalid = api.post(
|
|
"/api/uadmin/accounts",
|
|
json={"username": "bad user", "display_name": "Bad", "enrollment_mode": "oidc"},
|
|
)
|
|
unknown_mode = api.post(
|
|
"/api/uadmin/accounts",
|
|
json={
|
|
"username": "Mode.User",
|
|
"display_name": "Mode",
|
|
"email": random_email("mode"),
|
|
"enrollment_mode": "carrier_pigeon",
|
|
},
|
|
)
|
|
|
|
assert duplicate.status_code == 409
|
|
assert duplicate.json()["error"]["code"] == "username_taken"
|
|
assert invalid.status_code == 422
|
|
assert unknown_mode.status_code == 422
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# Listing, retrieval, isolation
|
|
# ----------------------------------------------------------------------
|
|
|
|
|
|
def test_list_and_get_accounts(api: ApiHarness, admin: object, service: AccountService) -> None:
|
|
provision_local(service, username="Member.One", role="member")
|
|
provision_local(service, username="Member.Two", role="member")
|
|
|
|
listing = api.get("/api/uadmin/accounts")
|
|
assert listing.status_code == 200
|
|
body = listing.json()
|
|
assert body["total"] == 3
|
|
assert {item["username"] for item in body["items"]} == {"Umbraadmin", "Member.One", "Member.Two"}
|
|
assert "password_hash" not in listing.text
|
|
assert "$argon2" not in listing.text
|
|
|
|
member = api.get("/api/uadmin/accounts?status=active&limit=1")
|
|
assert member.status_code == 200
|
|
assert member.json()["total"] == 3
|
|
assert len(member.json()["items"]) == 1
|
|
|
|
single = api.get(f"/api/uadmin/accounts/{admin.user_id}") # type: ignore[attr-defined]
|
|
assert single.status_code == 200
|
|
assert single.json()["id"] == str(admin.user_id) # type: ignore[attr-defined]
|
|
|
|
missing = api.get(f"/api/uadmin/accounts/{uuid4()}")
|
|
assert missing.status_code == 404
|
|
assert missing.json()["error"]["code"] == "account_not_found"
|
|
|
|
|
|
def test_organization_admin_scope_is_enforced(api: ApiHarness, service: AccountService) -> None:
|
|
org_a = ensure_organization(service, name="Org A").id
|
|
org_b = ensure_organization(service, name="Org B").id
|
|
activated_admin(service, username="Umbraadmin")
|
|
org_admin = activated_admin(service, username="Org.Admin", role="organization_admin", organization_id=org_a)
|
|
inside = provision_local(service, username="Inside.User", role="member", organization_id=org_a)
|
|
outside = provision_local(service, username="Outside.User", role="member", organization_id=org_b)
|
|
api.login_ok(org_admin)
|
|
|
|
listing = api.get("/api/uadmin/accounts")
|
|
assert listing.status_code == 200
|
|
assert listing.json()["total"] == 2
|
|
assert str(outside.user_id) not in listing.text
|
|
assert str(inside.user_id) in listing.text
|
|
|
|
# A forged organization filter cannot widen the scope.
|
|
forged = api.get(f"/api/uadmin/accounts?organization_id={org_b}")
|
|
assert forged.status_code == 200
|
|
assert forged.json()["total"] == 2
|
|
|
|
assert api.get(f"/api/uadmin/accounts/{outside.user_id}").status_code == 403
|
|
assert api.patch(f"/api/uadmin/accounts/{outside.user_id}/status", json={"status": "suspended"}).status_code == 403
|
|
assert api.post(f"/api/uadmin/accounts/{outside.user_id}/revoke-sessions").status_code == 403
|
|
# Creating an account outside its own organization is refused.
|
|
escape = api.post(
|
|
"/api/uadmin/accounts",
|
|
json={
|
|
"username": "Escape.User",
|
|
"display_name": "Escape",
|
|
"email": random_email("escape"),
|
|
"enrollment_mode": "oidc",
|
|
"organization_id": str(org_b),
|
|
},
|
|
)
|
|
assert escape.status_code == 403
|
|
# Nor can it create a platform administrator.
|
|
escalate = api.post(
|
|
"/api/uadmin/accounts",
|
|
json={
|
|
"username": "Escalate.User",
|
|
"display_name": "Escalate",
|
|
"email": random_email("escalate"),
|
|
"enrollment_mode": "oidc",
|
|
"role": "platform_admin",
|
|
"organization_id": str(org_a),
|
|
},
|
|
)
|
|
assert escalate.status_code == 403
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# Lifecycle
|
|
# ----------------------------------------------------------------------
|
|
|
|
|
|
def test_suspend_reactivate_and_deactivate(api: ApiHarness, admin: object, service: AccountService) -> None:
|
|
member = provision_local(service, username="Member.User", role="member")
|
|
# A separate browser holds the member's live session while the
|
|
# administrator acts through the first client.
|
|
with TestClient(api.client.app) as member_client:
|
|
result = member_client.post("/api/auth/login", json={"username": member.username, "password": member.password})
|
|
assert result.status_code == 200
|
|
|
|
suspended = api.patch(
|
|
f"/api/uadmin/accounts/{member.user_id}/status",
|
|
json={"status": "suspended", "reason": "policy review"},
|
|
)
|
|
assert suspended.status_code == 200
|
|
assert suspended.json()["account"]["status"] == "suspended"
|
|
assert suspended.json()["revoked_sessions"] == 1
|
|
# The suspended user's live session is dead.
|
|
assert member_client.get("/api/auth/me").status_code == 401
|
|
|
|
reactivated = api.patch(
|
|
f"/api/uadmin/accounts/{member.user_id}/status",
|
|
json={"status": "active", "reason": "cleared"},
|
|
)
|
|
assert reactivated.status_code == 200
|
|
assert reactivated.json()["account"]["status"] == "active"
|
|
|
|
deactivated = api.patch(f"/api/uadmin/accounts/{member.user_id}/status", json={"status": "deactivated"})
|
|
assert deactivated.status_code == 200
|
|
assert deactivated.json()["account"]["status"] == "deactivated"
|
|
|
|
# Logging in as the deactivated account is refused.
|
|
assert api.login(member.username, member.password).status_code == 401
|
|
|
|
|
|
def test_status_change_of_the_last_platform_admin_is_refused(api: ApiHarness, admin: object) -> None:
|
|
response = api.patch(
|
|
f"/api/uadmin/accounts/{admin.user_id}/status", # type: ignore[attr-defined]
|
|
json={"status": "suspended"},
|
|
)
|
|
assert response.status_code == 409
|
|
assert response.json()["error"]["code"] == "last_platform_admin"
|
|
|
|
|
|
def test_revoke_sessions_endpoint(api: ApiHarness, admin: object, service: AccountService) -> None:
|
|
member = provision_local(service, username="Member.User", role="member")
|
|
with TestClient(api.client.app) as member_client:
|
|
result = member_client.post("/api/auth/login", json={"username": member.username, "password": member.password})
|
|
assert result.status_code == 200
|
|
|
|
revoked = api.post(f"/api/uadmin/accounts/{member.user_id}/revoke-sessions")
|
|
assert revoked.status_code == 200
|
|
assert revoked.json()["revoked_sessions"] == 1
|
|
assert member_client.get("/api/auth/me").status_code == 401
|
|
|
|
# Idempotent: nothing is left to revoke.
|
|
assert api.post(f"/api/uadmin/accounts/{member.user_id}/revoke-sessions").json() == {"revoked_sessions": 0}
|
|
|
|
|
|
def test_admin_cannot_suspend_itself_into_a_lockout(api: ApiHarness, admin: object, service: AccountService) -> None:
|
|
second_admin = activated_admin(service, username="Second.Admin")
|
|
# A second platform administrator exists, so the first may step down.
|
|
response = api.patch(
|
|
f"/api/uadmin/accounts/{admin.user_id}/status", # type: ignore[attr-defined]
|
|
json={"status": "suspended", "reason": "handover"},
|
|
)
|
|
assert response.status_code == 200
|
|
assert api.client.get("/api/auth/me").status_code == 401
|
|
assert second_admin.username == "Second.Admin"
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# Audit
|
|
# ----------------------------------------------------------------------
|
|
|
|
|
|
def test_audit_endpoint_returns_sanitized_events(api: ApiHarness, admin: object, service: AccountService) -> None:
|
|
member = provision_local(service, username="Member.User", role="member")
|
|
api.patch(
|
|
f"/api/uadmin/accounts/{member.user_id}/status",
|
|
json={"status": "suspended", "reason": f"password={random_password()}"},
|
|
)
|
|
|
|
response = api.get("/api/uadmin/audit")
|
|
assert response.status_code == 200
|
|
body = response.json()
|
|
assert body["total"] >= 1
|
|
actions = {item["action"] for item in body["items"]}
|
|
assert "account.status_changed" in actions
|
|
assert "auth.login" in actions
|
|
|
|
for item in body["items"]:
|
|
assert set(item) == {
|
|
"id",
|
|
"occurred_at",
|
|
"action",
|
|
"outcome",
|
|
"actor_user_id",
|
|
"target_user_id",
|
|
"organization_id",
|
|
"request_id",
|
|
"detail",
|
|
}
|
|
assert "$argon2" not in str(item)
|
|
assert "password" not in response.text or '"password"' not in response.text
|
|
|
|
|
|
def test_audit_is_scoped_to_the_callers_organization(api: ApiHarness, service: AccountService) -> None:
|
|
org_a = ensure_organization(service, name="Org A").id
|
|
org_b = ensure_organization(service, name="Org B").id
|
|
activated_admin(service, username="Umbraadmin")
|
|
org_admin = activated_admin(service, username="Org.Admin", role="organization_admin", organization_id=org_a)
|
|
other = provision_local(service, username="Other.User", role="member", organization_id=org_b)
|
|
api.login_ok(org_admin)
|
|
|
|
response = api.get("/api/uadmin/audit")
|
|
assert response.status_code == 200
|
|
assert str(other.user_id) not in response.text
|
|
|
|
filtered = api.get(f"/api/uadmin/audit?target_user_id={other.user_id}")
|
|
assert filtered.status_code == 403
|
|
|
|
|
|
def test_audit_events_reference_the_request_id(api: ApiHarness, admin: object, service: AccountService) -> None:
|
|
response = api.post(
|
|
"/api/uadmin/accounts",
|
|
json={
|
|
"username": "Traced.User",
|
|
"display_name": "Traced",
|
|
"email": random_email("traced"),
|
|
"enrollment_mode": "oidc",
|
|
},
|
|
)
|
|
assert response.status_code == 201
|
|
request_id = response.headers["x-request-id"]
|
|
|
|
events = api.get(f"/api/uadmin/audit?target_user_id={response.json()['account']['id']}").json()
|
|
assert any(event["request_id"] == request_id for event in events["items"])
|
|
assert all(event["request_id"] for event in events["items"] if event["action"] == "account.created")
|