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.
492 lines
22 KiB
Python
492 lines
22 KiB
Python
"""Organization service tests: tenancy lifecycle, scoping, and session revocation."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from uuid import uuid4
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
from umbratrans_web.errors import (
|
|
ConflictError,
|
|
ForbiddenError,
|
|
InputError,
|
|
InvalidCredentialsError,
|
|
NotFoundError,
|
|
StateTransitionError,
|
|
)
|
|
from umbratrans_web.models import AccountStatus, OrganizationStatus, RoleName
|
|
from umbratrans_web.service import AccountService
|
|
|
|
from .conftest import (
|
|
ApiHarness,
|
|
activated_admin,
|
|
admin_principal,
|
|
ensure_organization,
|
|
provision_local,
|
|
provision_oidc,
|
|
random_email,
|
|
random_password,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def platform_admin(service: AccountService) -> object:
|
|
"""An activated platform administrator principal, without HTTP."""
|
|
|
|
return admin_principal(activated_admin(service, username="Umbraadmin"))
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# Naming
|
|
# ----------------------------------------------------------------------
|
|
|
|
|
|
def test_slug_is_derived_from_the_name(service: AccountService, platform_admin: object) -> None:
|
|
organization = service.create_organization(platform_admin, name="Northstar Media") # type: ignore[arg-type]
|
|
assert organization.slug == "northstar-media"
|
|
|
|
|
|
def test_supplied_slug_is_validated_as_written(service: AccountService, platform_admin: object) -> None:
|
|
organization = service.create_organization( # type: ignore[arg-type]
|
|
platform_admin, name="Harbor House", slug="harbor-house-2"
|
|
)
|
|
assert organization.slug == "harbor-house-2"
|
|
|
|
|
|
def test_rejected_slug_reports_the_slug_rule(service: AccountService, platform_admin: object) -> None:
|
|
with pytest.raises(InputError) as error:
|
|
service.create_organization(platform_admin, name="Bad Slug", slug="Not A Slug") # type: ignore[arg-type]
|
|
assert error.value.code == "invalid_organization_slug"
|
|
|
|
|
|
def test_a_name_with_nothing_slug_safe_is_rejected(service: AccountService, platform_admin: object) -> None:
|
|
with pytest.raises(InputError) as error:
|
|
service.create_organization(platform_admin, name="///") # type: ignore[arg-type]
|
|
assert error.value.code in {"invalid_organization_name", "invalid_organization_slug"}
|
|
|
|
|
|
def test_duplicate_slug_is_rejected(service: AccountService, platform_admin: object) -> None:
|
|
service.create_organization(platform_admin, name="Harbor House", slug="harbor-house") # type: ignore[arg-type]
|
|
with pytest.raises(ConflictError) as error:
|
|
service.create_organization(platform_admin, name="Other House", slug="harbor-house") # type: ignore[arg-type]
|
|
assert error.value.code == "organization_slug_taken"
|
|
|
|
|
|
def test_a_supplied_slug_must_already_be_canonical(service: AccountService, platform_admin: object) -> None:
|
|
"""A slug the operator typed is never silently rewritten."""
|
|
|
|
with pytest.raises(InputError) as error:
|
|
service.create_organization(platform_admin, name="Harbor House", slug="Harbor-House") # type: ignore[arg-type]
|
|
assert error.value.code == "invalid_organization_slug"
|
|
assert service.repository.get_organization_by_slug("harbor-house") is None
|
|
|
|
|
|
def test_slug_lookup_is_case_insensitive(service: AccountService, platform_admin: object) -> None:
|
|
created = service.create_organization(platform_admin, name="Harbor House", slug="harbor-house") # type: ignore[arg-type]
|
|
stored = service.repository.get_organization_by_slug("HARBOR-HOUSE")
|
|
assert stored is not None
|
|
assert stored.id == created.id
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# Authorization
|
|
# ----------------------------------------------------------------------
|
|
|
|
|
|
def test_only_a_platform_admin_may_create_an_organization(service: AccountService) -> None:
|
|
org = ensure_organization(service, name="Org A")
|
|
org_admin = admin_principal(
|
|
activated_admin(service, username="Org.Admin", role="organization_admin", organization_id=org.id)
|
|
)
|
|
with pytest.raises(ForbiddenError):
|
|
service.create_organization(org_admin, name="Sneaky Org")
|
|
assert service.repository.get_organization_by_slug("sneaky-org") is None
|
|
|
|
|
|
def test_an_organization_admin_sees_only_their_own_organization(service: AccountService) -> None:
|
|
mine = ensure_organization(service, name="Mine")
|
|
theirs = ensure_organization(service, name="Theirs")
|
|
org_admin = admin_principal(
|
|
activated_admin(service, username="Org.Admin", role="organization_admin", organization_id=mine.id)
|
|
)
|
|
|
|
organizations, total = service.list_organizations(org_admin)
|
|
assert total == 1
|
|
assert organizations[0].id == mine.id
|
|
|
|
with pytest.raises(ForbiddenError):
|
|
service.get_organization(org_admin, theirs.id)
|
|
|
|
|
|
def test_a_platform_admin_sees_every_organization(service: AccountService, platform_admin: object) -> None:
|
|
for name in ("Alpha", "Beta", "Gamma"):
|
|
ensure_organization(service, name=name)
|
|
organizations, total = service.list_organizations(platform_admin) # type: ignore[arg-type]
|
|
assert total == 3
|
|
assert {organization.slug for organization in organizations} == {"alpha", "beta", "gamma"}
|
|
|
|
|
|
def test_members_are_forbidden_from_organization_routes(service: AccountService) -> None:
|
|
org = ensure_organization(service, name="Org A")
|
|
member = admin_principal(provision_local(service, username="Member.User", role="member", organization_id=org.id))
|
|
with pytest.raises(ForbiddenError):
|
|
service.list_organizations(member) # type: ignore[arg-type]
|
|
|
|
|
|
def test_a_temporary_password_session_cannot_manage_organizations(service: AccountService) -> None:
|
|
pending = provision_local(service, username="Pending.Admin", role="platform_admin")
|
|
result = service.authenticate(username=pending.username, password=pending.password)
|
|
from umbratrans_web.models import Principal
|
|
|
|
principal = Principal(
|
|
user_id=pending.user_id,
|
|
username=pending.username,
|
|
role=RoleName.PLATFORM_ADMIN,
|
|
session_id=result.session.session_id,
|
|
must_change_password=True,
|
|
)
|
|
from umbratrans_web.errors import PasswordChangeRequiredError
|
|
|
|
with pytest.raises(PasswordChangeRequiredError):
|
|
service.create_organization(principal, name="Blocked Org")
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# Lifecycle
|
|
# ----------------------------------------------------------------------
|
|
|
|
|
|
def test_rename_and_re_slug(service: AccountService, platform_admin: object) -> None:
|
|
organization = service.create_organization(platform_admin, name="Old Name", slug="old-name") # type: ignore[arg-type]
|
|
renamed = service.update_organization(platform_admin, organization.id, name="New Name") # type: ignore[arg-type]
|
|
assert renamed.name == "New Name"
|
|
assert renamed.slug == "old-name"
|
|
|
|
reslugged = service.update_organization(platform_admin, organization.id, slug="new-name") # type: ignore[arg-type]
|
|
assert reslugged.slug == "new-name"
|
|
stored = service.repository.get_organization(organization.id)
|
|
assert stored is not None
|
|
# One bump per update call, so two updates leave it at 3.
|
|
assert stored.version == 3
|
|
|
|
|
|
def test_update_requires_at_least_one_field(service: AccountService, platform_admin: object) -> None:
|
|
organization = service.create_organization(platform_admin, name="Untouched") # type: ignore[arg-type]
|
|
with pytest.raises(InputError) as error:
|
|
service.update_organization(platform_admin, organization.id) # type: ignore[arg-type]
|
|
assert error.value.code == "no_changes"
|
|
|
|
|
|
def test_suspension_revokes_member_sessions(service: AccountService, platform_admin: object) -> None:
|
|
org = ensure_organization(service, name="Revoke Org")
|
|
member = provision_local(service, username="Revoke.Member", role="member", organization_id=org.id)
|
|
service.authenticate(username=member.username, password=member.password)
|
|
assert service.repository.count_sessions(member.user_id) == 1
|
|
|
|
organization, revoked = service.set_organization_status(platform_admin, org.id, "suspended") # type: ignore[arg-type]
|
|
assert organization.status is OrganizationStatus.SUSPENDED
|
|
assert revoked == 1
|
|
assert service.repository.count_sessions(member.user_id) == 0
|
|
|
|
with pytest.raises(InvalidCredentialsError):
|
|
service.authenticate(username=member.username, password=member.password)
|
|
|
|
|
|
def test_reactivation_restores_login(service: AccountService, platform_admin: object) -> None:
|
|
org = ensure_organization(service, name="Restore Org")
|
|
member = provision_local(service, username="Restore.Member", role="member", organization_id=org.id)
|
|
service.set_organization_status(platform_admin, org.id, "suspended") # type: ignore[arg-type]
|
|
|
|
with pytest.raises(InvalidCredentialsError):
|
|
service.authenticate(username=member.username, password=member.password)
|
|
|
|
service.set_organization_status(platform_admin, org.id, "active") # type: ignore[arg-type]
|
|
# The credential is untouched, so the same password works again.
|
|
assert service.authenticate(username=member.username, password=member.password) is not None
|
|
|
|
|
|
def test_archived_is_terminal(service: AccountService, platform_admin: object) -> None:
|
|
org = ensure_organization(service, name="Archive Org")
|
|
archived, _ = service.set_organization_status(platform_admin, org.id, "archived") # type: ignore[arg-type]
|
|
assert archived.status is OrganizationStatus.ARCHIVED
|
|
with pytest.raises(StateTransitionError):
|
|
service.set_organization_status(platform_admin, org.id, "active") # type: ignore[arg-type]
|
|
|
|
|
|
def test_unchanged_status_is_rejected(service: AccountService, platform_admin: object) -> None:
|
|
org = ensure_organization(service, name="Steady Org")
|
|
with pytest.raises(StateTransitionError) as error:
|
|
service.set_organization_status(platform_admin, org.id, "active") # type: ignore[arg-type]
|
|
assert error.value.code == "status_unchanged"
|
|
|
|
|
|
def test_unknown_organization_is_not_found(service: AccountService, platform_admin: object) -> None:
|
|
from uuid import uuid4
|
|
|
|
with pytest.raises(NotFoundError) as error:
|
|
service.get_organization(platform_admin, uuid4()) # type: ignore[arg-type]
|
|
assert error.value.code == "organization_not_found"
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# Membership validation
|
|
# ----------------------------------------------------------------------
|
|
|
|
|
|
def test_an_account_cannot_be_assigned_to_an_unknown_organization(
|
|
service: AccountService, platform_admin: object
|
|
) -> None:
|
|
with pytest.raises(NotFoundError) as error:
|
|
service.create_oidc_account(
|
|
username="Orphan",
|
|
display_name="Orphan",
|
|
email=random_email("orphan"),
|
|
organization_id=uuid4(),
|
|
actor=platform_admin, # type: ignore[arg-type]
|
|
)
|
|
assert error.value.code == "organization_not_found"
|
|
|
|
|
|
def test_an_account_cannot_be_assigned_to_a_suspended_organization(
|
|
service: AccountService, platform_admin: object
|
|
) -> None:
|
|
org = ensure_organization(service, name="Dormant Org", status=OrganizationStatus.SUSPENDED)
|
|
with pytest.raises(InputError) as error:
|
|
service.create_oidc_account(
|
|
username="Late.Joiner",
|
|
display_name="Late Joiner",
|
|
email=random_email("late-joiner"),
|
|
organization_id=org.id,
|
|
actor=platform_admin, # type: ignore[arg-type]
|
|
)
|
|
assert error.value.code == "organization_not_active"
|
|
|
|
|
|
def test_member_count_reflects_real_membership(service: AccountService, platform_admin: object) -> None:
|
|
org = ensure_organization(service, name="Counted Org")
|
|
assert service.get_organization(platform_admin, org.id).member_count == 0 # type: ignore[arg-type]
|
|
provision_oidc(service, platform_admin, username="Count.One", organization_id=org.id) # type: ignore[arg-type]
|
|
provision_local(service, username="Count.Two", role="member", organization_id=org.id)
|
|
assert service.get_organization(platform_admin, org.id).member_count == 2 # type: ignore[arg-type]
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# Summary
|
|
# ----------------------------------------------------------------------
|
|
|
|
|
|
def test_summary_is_scoped_to_an_organization_admin(service: AccountService) -> None:
|
|
mine = ensure_organization(service, name="Mine")
|
|
ensure_organization(service, name="Theirs")
|
|
org_admin = admin_principal(
|
|
activated_admin(service, username="Org.Admin", role="organization_admin", organization_id=mine.id)
|
|
)
|
|
provision_local(service, username="Mine.Member", role="member", organization_id=mine.id)
|
|
provision_local(service, username="Theirs.Member", role="member", organization_id=None)
|
|
|
|
summary = service.organization_summary(org_admin)
|
|
assert summary["total_organizations"] == 1
|
|
# The admin's own account plus the one member, not the unrelated member.
|
|
assert summary["total_accounts"] == 2
|
|
|
|
|
|
def test_summary_counts_pending_first_logins(service: AccountService, platform_admin: object) -> None:
|
|
org = ensure_organization(service, name="Pending Org")
|
|
provision_local(service, username="Still.Temp", role="member", organization_id=org.id)
|
|
# A completed first-login change clears the flag.
|
|
pending = provision_local(service, username="Already.Changed", role="member", organization_id=org.id)
|
|
result = service.authenticate(username=pending.username, password=pending.password)
|
|
service.change_first_password(
|
|
service.resolve_session(result.session.token),
|
|
current_password=pending.password,
|
|
new_password=random_password(30),
|
|
)
|
|
|
|
summary = service.organization_summary(platform_admin) # type: ignore[arg-type]
|
|
assert summary["pending_first_login"] == 1
|
|
assert summary["total_accounts"] == 3
|
|
assert summary["active_accounts"] == 3
|
|
|
|
|
|
def test_summary_reports_suspended_accounts(service: AccountService, platform_admin: object) -> None:
|
|
org = ensure_organization(service, name="Mixed Org")
|
|
member = provision_local(service, username="To.Suspend", role="member", organization_id=org.id)
|
|
service.set_account_status(
|
|
platform_admin,
|
|
member.user_id,
|
|
"suspended", # type: ignore[arg-type]
|
|
)
|
|
summary = service.organization_summary(platform_admin) # type: ignore[arg-type]
|
|
assert summary["suspended_accounts"] == 1
|
|
assert summary["active_accounts"] == 1
|
|
assert summary["total_accounts"] == 2
|
|
|
|
|
|
def test_suspended_account_stays_visible_in_the_member_count(service: AccountService, platform_admin: object) -> None:
|
|
org = ensure_organization(service, name="Still Counted")
|
|
member = provision_local(service, username="Suspended.Member", role="member", organization_id=org.id)
|
|
service.set_account_status(platform_admin, member.user_id, AccountStatus.SUSPENDED.value) # type: ignore[arg-type]
|
|
# Membership is not erased by suspension; the count stays truthful.
|
|
assert service.get_organization(platform_admin, org.id).member_count == 1 # type: ignore[arg-type]
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# HTTP surface
|
|
# ----------------------------------------------------------------------
|
|
|
|
|
|
@pytest.fixture
|
|
def signed_in_admin(api: ApiHarness, service: AccountService) -> ApiHarness:
|
|
"""A harness carrying an activated platform administrator session."""
|
|
|
|
api.login_ok(activated_admin(service, username="Umbraadmin"))
|
|
return api
|
|
|
|
|
|
def test_organization_routes_require_a_session(client: TestClient) -> None:
|
|
for method, path in [
|
|
("get", "/api/uadmin/summary"),
|
|
("get", "/api/uadmin/organizations"),
|
|
("get", f"/api/uadmin/organizations/{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/organizations"),
|
|
("patch", f"/api/uadmin/organizations/{uuid4()}"),
|
|
("patch", f"/api/uadmin/organizations/{uuid4()}/status"),
|
|
]:
|
|
response = getattr(client, method)(path, json={})
|
|
assert response.status_code in {401, 403}, path
|
|
|
|
|
|
def test_platform_admin_manages_an_organization_over_http(signed_in_admin: ApiHarness) -> None:
|
|
created = signed_in_admin.post(
|
|
"/api/uadmin/organizations",
|
|
json={"name": "Northstar Media", "slug": "northstar-media"},
|
|
)
|
|
assert created.status_code == 201
|
|
body = created.json()
|
|
assert (body["name"], body["slug"], body["status"], body["member_count"]) == (
|
|
"Northstar Media",
|
|
"northstar-media",
|
|
"active",
|
|
0,
|
|
)
|
|
|
|
organization_id = body["id"]
|
|
assert signed_in_admin.get(f"/api/uadmin/organizations/{organization_id}").status_code == 200
|
|
assert signed_in_admin.get("/api/uadmin/organizations").json()["total"] == 1
|
|
|
|
renamed = signed_in_admin.patch(f"/api/uadmin/organizations/{organization_id}", json={"name": "Northstar Studios"})
|
|
assert renamed.status_code == 200
|
|
assert renamed.json()["name"] == "Northstar Studios"
|
|
assert renamed.json()["slug"] == "northstar-media"
|
|
|
|
suspended = signed_in_admin.patch(
|
|
f"/api/uadmin/organizations/{organization_id}/status", json={"status": "suspended"}
|
|
)
|
|
assert suspended.status_code == 200
|
|
assert suspended.json()["organization"]["status"] == "suspended"
|
|
|
|
# archived is terminal, so the reverse move is refused.
|
|
assert (
|
|
signed_in_admin.patch(
|
|
f"/api/uadmin/organizations/{organization_id}/status", json={"status": "archived"}
|
|
).status_code
|
|
== 200
|
|
)
|
|
conflict = signed_in_admin.patch(f"/api/uadmin/organizations/{organization_id}/status", json={"status": "active"})
|
|
assert conflict.status_code == 409
|
|
assert conflict.json()["error"]["code"] == "invalid_state_transition"
|
|
|
|
|
|
def test_duplicate_slug_over_http_is_a_conflict(signed_in_admin: ApiHarness) -> None:
|
|
signed_in_admin.post("/api/uadmin/organizations", json={"name": "Harbor House", "slug": "harbor-house"})
|
|
duplicate = signed_in_admin.post("/api/uadmin/organizations", json={"name": "Other", "slug": "harbor-house"})
|
|
assert duplicate.status_code == 409
|
|
assert duplicate.json()["error"]["code"] == "organization_slug_taken"
|
|
|
|
|
|
def test_organization_admin_is_read_only_and_scoped(api: ApiHarness, service: AccountService) -> None:
|
|
mine = ensure_organization(service, name="Mine")
|
|
theirs = ensure_organization(service, name="Theirs")
|
|
org_admin = activated_admin(service, username="Org.Admin", role="organization_admin", organization_id=mine.id)
|
|
api.login_ok(org_admin)
|
|
|
|
listed = api.get("/api/uadmin/organizations").json()
|
|
assert listed["total"] == 1
|
|
assert listed["items"][0]["id"] == str(mine.id)
|
|
assert str(theirs.id) not in api.get("/api/uadmin/organizations").text
|
|
|
|
assert api.get(f"/api/uadmin/organizations/{theirs.id}").status_code == 403
|
|
assert api.post("/api/uadmin/organizations", json={"name": "Sneaky"}).status_code == 403
|
|
assert api.patch(f"/api/uadmin/organizations/{mine.id}", json={"name": "Renamed"}).status_code == 403
|
|
assert api.patch(f"/api/uadmin/organizations/{mine.id}/status", json={"status": "suspended"}).status_code == 403
|
|
# Reading its own organization still works.
|
|
assert api.get(f"/api/uadmin/organizations/{mine.id}").status_code == 200
|
|
|
|
|
|
def test_members_cannot_read_organizations(api: ApiHarness, service: AccountService) -> None:
|
|
org = ensure_organization(service, name="Org A")
|
|
member = activated_admin(service, username="Plain.Member", role="member", organization_id=org.id)
|
|
api.login_ok(member)
|
|
assert api.get("/api/uadmin/organizations").status_code == 403
|
|
assert api.get("/api/uadmin/summary").status_code == 403
|
|
|
|
|
|
def test_account_creation_validates_the_organization(signed_in_admin: ApiHarness) -> None:
|
|
unknown = signed_in_admin.post(
|
|
"/api/uadmin/accounts",
|
|
json={
|
|
"username": "Orphan",
|
|
"display_name": "Orphan",
|
|
"email": random_email("orphan"),
|
|
"enrollment_mode": "temporary_password",
|
|
"organization_id": str(uuid4()),
|
|
},
|
|
)
|
|
assert unknown.status_code == 404
|
|
assert unknown.json()["error"]["code"] == "organization_not_found"
|
|
|
|
organization = signed_in_admin.post("/api/uadmin/organizations", json={"name": "Dormant"}).json()
|
|
signed_in_admin.patch(f"/api/uadmin/organizations/{organization['id']}/status", json={"status": "suspended"})
|
|
dormant = signed_in_admin.post(
|
|
"/api/uadmin/accounts",
|
|
json={
|
|
"username": "Late.Joiner",
|
|
"display_name": "Late",
|
|
"email": random_email("late"),
|
|
"enrollment_mode": "temporary_password",
|
|
"organization_id": organization["id"],
|
|
},
|
|
)
|
|
assert dormant.status_code == 422
|
|
assert dormant.json()["error"]["code"] == "organization_not_active"
|
|
|
|
|
|
def test_suspending_an_organization_locks_its_members_out_over_http(
|
|
signed_in_admin: ApiHarness, service: AccountService
|
|
) -> None:
|
|
org = ensure_organization(service, name="Lockout Org")
|
|
member = provision_local(service, username="Locked.Member", role="member", organization_id=org.id)
|
|
signed_in_admin.patch(f"/api/uadmin/organizations/{org.id}/status", json={"status": "suspended"})
|
|
# The account itself is still active; only the tenant is suspended.
|
|
assert service.repository.get_user(member.user_id).status is AccountStatus.ACTIVE
|
|
response = signed_in_admin.login(member.username, member.password)
|
|
assert response.status_code == 401
|
|
assert response.json()["error"]["code"] == "invalid_credentials"
|
|
|
|
|
|
def test_summary_over_http_is_scoped(api: ApiHarness, service: AccountService) -> None:
|
|
mine = ensure_organization(service, name="Mine")
|
|
ensure_organization(service, name="Theirs")
|
|
org_admin = activated_admin(service, username="Org.Admin", role="organization_admin", organization_id=mine.id)
|
|
api.login_ok(org_admin)
|
|
summary = api.get("/api/uadmin/summary").json()
|
|
assert summary["total_organizations"] == 1
|
|
assert summary["total_accounts"] == 1
|