Implement role-based account journeys with database and browser acceptance suites
Some checks failed
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Container Image / build-and-push (push) Successful in 20s
Account journey acceptance / journeys (push) Failing after 0s

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a092fe-13b1-7f12-ac74-7d258af4d79c
This commit is contained in:
tegwick 2026-09-13 12:20:02 +02:00
parent 75750c0036
commit 1127f852dd
24 changed files with 1554 additions and 148 deletions

291
tests/journey-coverage.json Normal file
View file

@ -0,0 +1,291 @@
{
"schema_version": 1,
"journeys": [
{
"id": "U01",
"role": "user",
"implementation": "implemented",
"tests": [
"test_account_clarity.AccountClarityTests.test_anonymous_and_expired_sessions_have_login_without_logout",
"test_account_clarity.AccountClarityTests.test_authenticated_roles_have_logout_without_login"
],
"remaining": ""
},
{
"id": "U02",
"role": "user",
"implementation": "external-blocked",
"tests": [
"test_web.PortalApplicationTests.test_expired_browser_session_and_provider_outage_fail_closed",
"test_account_recovery.AccountRecoveryTests.test_failed_callback_has_clean_recovery_and_no_loop"
],
"remaining": "KEY-WP-0035: actual no-factor/enrolled login needs provider credential and policy rollout."
},
{
"id": "U03",
"role": "user",
"implementation": "external-blocked",
"tests": [
"test_web.PortalApplicationTests.test_browser_invitation_acceptance_and_onboarding_status",
"test_web.PortalApplicationTests.test_invitation_lifecycle_is_versioned_and_replay_safe"
],
"remaining": "Live notification delivery and invited-person acceptance remain unverified."
},
{
"id": "U04",
"role": "user",
"implementation": "external-blocked",
"tests": [
"test_journey_roles.UserJourneys.test_password_handoff_names_actual_login_and_failure_can_retry"
],
"remaining": "Provider password reset is separately tested in net-kingdom; live email recovery remains unresolved."
},
{
"id": "U05",
"role": "user",
"implementation": "external-blocked",
"tests": [
"test_account_clarity.AccountClarityTests.test_otp_help_is_available_without_portal_login_and_never_claims_activation"
],
"remaining": "Portal boundary only. KEY-WP-0035 owns factor presence/AAL2 enforcement; live enrollment, cancel, replacement and lost-factor recovery await approved provider credential/contract."
},
{
"id": "U06",
"role": "user",
"implementation": "external-blocked",
"tests": [
"test_account_clarity.AccountClarityTests.test_otp_help_is_available_without_portal_login_and_never_claims_activation"
],
"remaining": "Portal boundary only. KEY-WP-0035 owns factor presence/AAL2 enforcement; live enrollment, cancel, replacement and lost-factor recovery await approved provider credential/contract."
},
{
"id": "U07",
"role": "user",
"implementation": "external-blocked",
"tests": [
"test_account_clarity.AccountClarityTests.test_otp_help_is_available_without_portal_login_and_never_claims_activation"
],
"remaining": "Portal boundary only. KEY-WP-0035 owns factor presence/AAL2 enforcement; live enrollment, cancel, replacement and lost-factor recovery await approved provider credential/contract."
},
{
"id": "U08",
"role": "user",
"implementation": "external-blocked",
"tests": [
"test_account_clarity.AccountClarityTests.test_otp_help_is_available_without_portal_login_and_never_claims_activation"
],
"remaining": "Portal boundary only. KEY-WP-0035 owns factor presence/AAL2 enforcement; live enrollment, cancel, replacement and lost-factor recovery await approved provider credential/contract."
},
{
"id": "U09",
"role": "user",
"implementation": "partial",
"tests": [
"test_account_recovery.AccountRecoveryTests.test_account_workload_list_is_scoped_to_current_user"
],
"remaining": "USER-WP-0028-T02/USER-WP-0026-T03: authoritative application catalogue and access requests not implemented."
},
{
"id": "U10",
"role": "user",
"implementation": "external-blocked",
"tests": [
"test_account_clarity.AccountClarityTests.test_wrong_shared_identity_recovery_does_not_claim_a_known_session",
"test_portal_navigation.PortalNavigationTests.test_navigation_does_not_leak_between_operator_member_and_anonymous"
],
"remaining": "Authenticated multi-identity/tenant switching must be verified against live issuer."
},
{
"id": "U11",
"role": "user",
"implementation": "implemented",
"tests": [
"test_account_recovery.AccountRecoveryTests.test_shared_logout_clears_portal_then_uses_provider_confirmation",
"test_portal_navigation.PortalNavigationTests.test_get_logout_only_confirms_and_bad_csrf_does_not_end_session",
"test_portal_navigation.PortalNavigationTests.test_expired_session_logout_clears_stale_cookie"
],
"remaining": ""
},
{
"id": "U12",
"role": "user",
"implementation": "implemented",
"tests": [
"test_account_clarity.AccountClarityTests.test_browser_denial_is_recoverable_while_api_remains_json",
"test_account_recovery.AccountRecoveryTests.test_recovery_is_public_and_never_trusts_query_identity"
],
"remaining": ""
},
{
"id": "U13",
"role": "user",
"implementation": "implemented",
"tests": [
"test_journey_roles.UserJourneys.test_profile_validation_keeps_safe_input_and_retry_saves",
"test_web.PortalApplicationTests.test_browser_invitation_acceptance_and_onboarding_status"
],
"remaining": ""
},
{
"id": "T01",
"role": "tenant_admin",
"implementation": "implemented",
"tests": [
"test_journey_roles.TenantAdminJourneys.test_wrong_role_or_tenant_never_calls_provider",
"test_journey_roles.TenantAdminJourneys.test_audit_is_tenant_scoped_and_never_dumps_payload"
],
"remaining": ""
},
{
"id": "T02",
"role": "tenant_admin",
"implementation": "external-blocked",
"tests": [
"test_web.PortalApplicationTests.test_invitation_lifecycle_is_versioned_and_replay_safe",
"test_journey_roles.TenantAdminJourneys.test_invalid_role_cannot_create_partial_account",
"test_journey_roles.TenantAdminJourneys.test_invitation_delivery_reports_queue_failure_and_adapter_acceptance"
],
"remaining": "Local invitation queue/failure/adapter-acceptance states are implemented. Actual receipt by the invited person remains live provider acceptance under U03."
},
{
"id": "T03",
"role": "tenant_admin",
"implementation": "implemented",
"tests": [
"test_journey_roles.UserJourneys.test_password_handoff_names_actual_login_and_failure_can_retry",
"test_web.PortalApplicationTests.test_admin_form_requires_csrf_and_supports_two_step_provisioning"
],
"remaining": ""
},
{
"id": "T04",
"role": "tenant_admin",
"implementation": "external-blocked",
"tests": [
"test_journey_roles.UserJourneys.test_password_handoff_names_actual_login_and_failure_can_retry",
"test_journey_roles.TenantAdminJourneys.test_provider_failure_retains_local_state_and_retry_recovers"
],
"remaining": "Provider-owned lost-factor recovery still unverified; password setup assistance is supported."
},
{
"id": "T05",
"role": "tenant_admin",
"implementation": "partial",
"tests": [
"test_journey_roles.TenantAdminJourneys.test_invalid_role_cannot_create_partial_account"
],
"remaining": "Tenant roles are managed; authoritative application-specific grant/revoke integration remains USER-WP-0028-T02."
},
{
"id": "T06",
"role": "tenant_admin",
"implementation": "implemented",
"tests": [
"test_journey_roles.TenantAdminJourneys.test_confirmation_cancel_tamper_and_stale_state",
"test_journey_roles.TenantAdminJourneys.test_tenant_removal_preserves_shared_identity_and_other_account",
"test_journey_roles.TenantAdminJourneys.test_provider_failure_retains_local_state_and_retry_recovers",
"test_journey_roles.TenantAdminJourneys.test_disabled_account_cannot_be_reactivated_by_password_setup"
],
"remaining": ""
},
{
"id": "T07",
"role": "tenant_admin",
"implementation": "implemented",
"tests": [
"test_web.PortalApplicationTests.test_admin_form_requires_csrf_and_supports_two_step_provisioning"
],
"remaining": "Provider factor/password evidence remains explicitly unavailable; provider verification is tracked under U05-U08."
},
{
"id": "T08",
"role": "tenant_admin",
"implementation": "implemented",
"tests": [
"test_journey_roles.TenantAdminJourneys.test_last_admin_protected_then_successor_allows_transition",
"test_journey_roles.TenantAdminJourneys.test_admin_succession_promote_then_demote_with_confirmation",
"test_journey_roles.TenantAdminJourneys.test_audit_is_tenant_scoped_and_never_dumps_payload",
"test_journey_roles.TenantAdminJourneys.test_concurrent_admin_suspensions_keep_one_active_admin"
],
"remaining": "Disposable PostgreSQL cross-connection/rollback suite also passed; run its separate opt-in suite for DB changes."
},
{
"id": "P01",
"role": "platform_admin",
"implementation": "implemented",
"tests": [
"test_portal_navigation.PortalNavigationTests.test_operator_can_reach_administration_without_personal_membership",
"test_portal_navigation.PortalNavigationTests.test_existing_tenant_user_navigation_preserves_authority"
],
"remaining": ""
},
{
"id": "P02",
"role": "platform_admin",
"implementation": "implemented",
"tests": [
"test_journey_roles.PlatformAdminJourneys.test_bootstrap_retry_does_not_duplicate_first_admin",
"test_web.PortalApplicationTests.test_platform_tenant_authority_denial_is_redacted_and_creates_no_admin",
"test_journey_roles.PlatformAdminJourneys.test_partial_first_admin_setup_rolls_back_and_retry_finishes"
],
"remaining": "Local bootstrap is atomic and retryable; tenant creation remains delegated to its authority."
},
{
"id": "P03",
"role": "platform_admin",
"implementation": "implemented",
"tests": [
"test_portal_navigation.PortalNavigationTests.test_ambiguous_or_unknown_short_names_do_not_guess_a_tenant",
"test_portal_navigation.PortalNavigationTests.test_existing_tenant_is_selectable_and_short_name_resolves"
],
"remaining": ""
},
{
"id": "P04",
"role": "platform_admin",
"implementation": "external-blocked",
"tests": [
"test_journey_roles.PlatformAdminJourneys.test_recovery_uses_tenant_access_and_keeps_global_identity_operations_unused"
],
"remaining": "Tenant identity recovery is scoped; verified OTP/account-ownership recovery remains provider-owned."
},
{
"id": "P05",
"role": "platform_admin",
"implementation": "external-blocked",
"tests": [
"test_journey_roles.PlatformAdminJourneys.test_delivery_denial_redaction_retry_and_completed_guard"
],
"remaining": "Local delivery record operations work; approved factor credential renewal and mail receipt remain external dependencies."
},
{
"id": "P06",
"role": "platform_admin",
"implementation": "external-blocked",
"tests": [
"test_account_clarity.AccountClarityTests.test_provider_handoff_rejects_unsafe_configuration"
],
"remaining": "Boundary test only. KeyCape policy suite covers optional/required/step-up; live policy management and provider rollout remain KEY-WP-0035."
},
{
"id": "P07",
"role": "platform_admin",
"implementation": "implemented",
"tests": [
"test_journey_roles.PlatformAdminJourneys.test_tenant_retirement_requires_confirmation_and_stale_confirmation_fails",
"test_web.PortalApplicationTests.test_platform_tenant_lifecycle_is_delegated_to_the_authority"
],
"remaining": ""
},
{
"id": "P08",
"role": "platform_admin",
"implementation": "implemented",
"tests": [
"test_journey_roles.PlatformAdminJourneys.test_delivery_denial_redaction_retry_and_completed_guard",
"test_journey_roles.TenantAdminJourneys.test_audit_is_tenant_scoped_and_never_dumps_payload"
],
"remaining": ""
}
]
}

View file

@ -0,0 +1,94 @@
"""Opt-in acceptance against a disposable PostgreSQL database, never production."""
import unittest
from concurrent.futures import ThreadPoolExecutor
from threading import Barrier
from test_web import FakeProvisioning
from user_engine.adapters import LocalAuthorizationCheckPort
from user_engine.adapters.postgres import PostgresUserEngineStore
from user_engine.domain import AccountStatus, User
from user_engine.errors import ConflictError
from user_engine.service import UserEngineService
from user_engine.testing.fixtures import FixtureIdentityClaimsAdapter, human_actor_claims
from user_engine.testing.postgres_provider import postgres_provider_test_config, connect_postgres_provider, reset_user_engine_postgres_tables
from user_engine.web import PortalApplication
class PostgresJourneyTests(unittest.TestCase):
def setUp(self):
self.config,reason=postgres_provider_test_config()
if reason:self.skipTest(reason)
self.connections=[]
self.seed=self.connect()
reset_user_engine_postgres_tables(self.seed)
self.tenant='tenant:trial:concurrency'
claims=human_actor_claims(subject='operator',tenant='tenant:platform:root')
claims['roles']=['platform-operator']
self.actor=FixtureIdentityClaimsAdapter().normalize(claims)
self.provider=FakeProvisioning()
def connect(self):
connection=connect_postgres_provider(self.config.dsn)
self.connections.append(connection)
return connection
def service(self,connection):
return UserEngineService(store=PostgresUserEngineStore(connection),identity_adapter=FixtureIdentityClaimsAdapter(),authorization=LocalAuthorizationCheckPort())
def tearDown(self):
for connection in self.connections:connection.close()
def test_two_connections_cannot_disable_both_admins(self):
service=self.service(self.seed)
users=[]
for name in ['first','second']:
u=service.create_user(self.actor,display_name=name,primary_email=name+'@example.test')
service.set_tenant_account_status(self.actor,u.user_id,AccountStatus.ACTIVE,tenant=self.tenant)
service.add_membership(self.actor,u.user_id,tenant=self.tenant,scope_type='tenant',scope_id=self.tenant,kind='tenant-admin')
service.link_identity(self.actor,u.user_id,issuer='urn:netkingdom:directory',subject=name,provider='netkingdom-lldap')
users.append(u)
apps=[PortalApplication(self.service(self.connect()),trusted_proxy_secret='disposable-test-marker-only',login_url='https://test.example',provisioning=self.provider) for _ in users]
barrier=Barrier(2)
def disable(pair):
app,u=pair
barrier.wait(timeout=5)
try:
app._change_status(self.actor,self.tenant,u.user_id,AccountStatus.SUSPENDED,idempotency_key='test-'+u.user_id,correlation_id='concurrent-test')
return 'changed'
except ConflictError:return 'conflict'
with ThreadPoolExecutor(max_workers=2) as pool:
results=list(pool.map(disable,zip(apps,users)))
self.assertEqual(['changed','conflict'],sorted(results))
self.assertEqual(1,len(self.provider.actions))
self.assertEqual(1,sum(service.store.tenant_account(self.tenant,u.user_id).status==AccountStatus.ACTIVE for u in users))
def test_nested_bootstrap_rolls_back_all_local_records(self):
service=self.service(self.seed)
user_id=None
with self.assertRaises(RuntimeError):
with service.store.tenant_lifecycle_guard(self.tenant), service.store.transaction():
user=service.create_user(self.actor,display_name='Partial',primary_email='partial@example.test')
user_id=user.user_id
service.set_tenant_account_status(self.actor,user_id,AccountStatus.INVITED,tenant=self.tenant)
raise RuntimeError('failure before first-admin membership')
self.assertIsNone(service.store.user(user_id))
self.assertIsNone(service.store.tenant_account(self.tenant,user_id))
def test_guard_releases_after_exception(self):
first=PostgresUserEngineStore(self.seed)
second=PostgresUserEngineStore(self.connect())
with self.assertRaises(RuntimeError):
with first.tenant_lifecycle_guard(self.tenant):raise RuntimeError('simulated provider failure')
cursor=second.connection.cursor()
cursor.execute("SET statement_timeout = '2s'")
with second.tenant_lifecycle_guard(self.tenant):pass
cursor.close()
def test_successful_guard_does_not_commit_or_discard_caller_work(self):
first=PostgresUserEngineStore(self.seed)
second=PostgresUserEngineStore(self.connect())
u=User(user_id='pending-user',display_name='Pending')
first.save_user(u)
with first.tenant_lifecycle_guard(self.tenant):pass
self.assertIsNone(second.user(u.user_id))
self.seed.commit()
self.assertEqual(u,second.user(u.user_id))

287
tests/test_journey_roles.py Normal file
View file

@ -0,0 +1,287 @@
"""Repeatable user, tenant-admin and platform-admin acceptance journeys.
These tests exercise public WSGI routes and persisted state. Provider doubles are
explicit: they prove portal orchestration, not live OTP/mail/directory behavior.
"""
from dataclasses import replace
from html import unescape
import re
import unittest
from urllib.parse import quote
import test_portal_navigation
from test_web import invoke, invoke_confirmed, FakeProvisioning
from user_engine.domain import AccountStatus, OutboxEvent, utc_now
from user_engine.oidc import BrowserSession
from user_engine.testing.fixtures import human_actor_claims
TENANT='tenant:trial:demo-company'
OTHER='tenant:trial:other'
class JourneyFixture(unittest.TestCase):
def setUp(self):
test_portal_navigation.PortalNavigationTests.setUp(self)
self.app.provisioning=FakeProvisioning()
claims=human_actor_claims(subject='tenant-admin',tenant=TENANT)
claims['roles']=['tenant-admin']
self.oidc.sessions['admin']=BrowserSession(claims,9999999999,'admin-csrf')
self.actor=self.app.service.identity_adapter.normalize(self.oidc.claims('operator'))
def member(self, tenant=TENANT, role='user', email='person@example.test', linked=True):
user=self.app.service.create_user(self.actor,display_name='Display name',primary_email=email)
self.app.service.set_tenant_account_status(self.actor,user.user_id,AccountStatus.ACTIVE,tenant=tenant)
self.app.service.add_membership(self.actor,user.user_id,tenant=tenant,scope_type='tenant',scope_id=tenant,kind=role)
if linked:
self.app.service.link_identity(self.actor,user.user_id,issuer='urn:netkingdom:directory',
subject='uid='+email.split('@')[0]+',ou=people,dc=netkingdom,dc=local',provider='netkingdom-lldap')
return user
def post(self,path,who='admin',confirmed=False,**form):
form.setdefault('csrf_token',who+'-csrf')
call=invoke_confirmed if confirmed else invoke
return call(self.app,path,method='POST',cookie='ue_session='+who,form=form)
def confirm_token(self,body):
return unescape(re.search(rb'name="confirm_token" value="([^"]+)"',body).group(1).decode())
class UserJourneys(JourneyFixture):
def test_profile_validation_keeps_safe_input_and_retry_saves(self):
invoke(self.app,'/onboarding',cookie='ue_session=member')
session=self.app.service.me(self.oidc.claims('member'),correlation_id='before')
original=session.user.display_name
response,body=self.post('/onboarding/profile',who='member',display_name='<script>'+('x'*200),consent_accepted='yes')
self.assertEqual('400 Bad Request',response['status'])
self.assertIn(b'&lt;script&gt;',body)
self.assertNotIn(b'<script>',body)
self.assertIn(b'Your profile has not been saved',body)
self.assertEqual(original,self.app.service.store.user(session.user.user_id).display_name)
response,body=self.post('/onboarding/profile',who='member',display_name='Chosen display name',consent_accepted='yes')
self.assertEqual('200 OK',response['status'])
self.assertIn(b'Profile saved',body)
self.assertEqual('Chosen display name',self.app.service.store.user(session.user.user_id).display_name)
def test_password_handoff_names_actual_login_and_failure_can_retry(self):
user=self.member(email='actual.login@example.test',linked=False)
original=self.app.provisioning.provision
def fail(request): raise RuntimeError('private-provider-detail')
self.app.provisioning.provision=fail
path=f'/admin/{TENANT}/users/{user.user_id}/provision'
response,body=self.post(path)
self.assertEqual('502 Bad Gateway',response['status'])
self.assertNotIn(b'private-provider-detail',body)
self.assertFalse(self.app.service.store.identities_for_user(user.user_id))
self.app.provisioning.provision=original
response,body=self.post(path)
self.assertEqual('200 OK',response['status'])
self.assertIn(b'Login name: <strong>actual.login</strong>',body)
self.assertIn(b'may differ from the display name',body)
self.assertEqual(1,len(self.app.service.store.identities_for_user(user.user_id)))
class TenantAdminJourneys(JourneyFixture):
def test_wrong_role_or_tenant_never_calls_provider(self):
user=self.member(tenant=OTHER)
for who in ['admin','member']:
for action in ['provision','status','remove','recover']:
response,_=self.post(f'/admin/{TENANT}/users/{user.user_id}/{action}',who=who,status='suspended')
self.assertIn(response['status'],['403 Forbidden','404 Not Found'])
self.assertEqual([],self.app.provisioning.requests)
self.assertEqual([],self.app.provisioning.actions)
response,_=invoke(self.app,f'/api/v1/tenants/{TENANT}/users',cookie='ue_session=member')
self.assertEqual('403 Forbidden',response['status'])
def test_confirmation_cancel_tamper_and_stale_state(self):
user=self.member();path=f'/admin/{TENANT}/users/{user.user_id}/status'
response,body=self.post(path,status='suspended')
self.assertEqual('200 OK',response['status'])
self.assertIn(b'Confirm change',body)
self.assertEqual([],self.app.provisioning.actions)
self.assertEqual(AccountStatus.ACTIVE,self.app.service.store.tenant_account(TENANT,user.user_id).status)
token=self.confirm_token(body)
response,_=self.post(path,status='active',confirm_token=token)
self.assertEqual('409 Conflict',response['status'])
self.app.service.store.save_tenant_account(replace(self.app.service.store.tenant_account(TENANT,user.user_id),status=AccountStatus.INVITED))
response,_=self.post(path,status='suspended',confirm_token=token)
self.assertEqual('409 Conflict',response['status'])
self.assertEqual([],self.app.provisioning.actions)
def test_tenant_removal_preserves_shared_identity_and_other_account(self):
user=self.member()
self.app.service.set_tenant_account_status(self.actor,user.user_id,AccountStatus.ACTIVE,tenant=OTHER)
self.app.service.add_membership(self.actor,user.user_id,tenant=OTHER,scope_type='tenant',scope_id=OTHER,kind='user')
response,_=self.post(f'/admin/{TENANT}/users/{user.user_id}/remove',confirmed=True)
self.assertEqual('303 See Other',response['status'])
self.assertEqual(AccountStatus.DISABLED,self.app.service.store.tenant_account(TENANT,user.user_id).status)
self.assertEqual(AccountStatus.ACTIVE,self.app.service.store.tenant_account(OTHER,user.user_id).status)
self.assertTrue(self.app.service.store.identities_for_user(user.user_id))
self.assertEqual([('tenant_disabled','uid=person,ou=people,dc=netkingdom,dc=local',TENANT)],self.app.provisioning.actions)
def test_last_admin_protected_then_successor_allows_transition(self):
first=self.member(role='tenant-admin',email='first@example.test')
path=f'/admin/{TENANT}/users/{first.user_id}/status'
response,_=self.post(path,status='suspended',confirmed=True)
self.assertEqual('409 Conflict',response['status'])
self.assertEqual([],self.app.provisioning.actions)
self.member(role='tenant-admin',email='successor@example.test')
response,_=self.post(path,status='suspended',confirmed=True)
self.assertEqual('303 See Other',response['status'])
self.assertEqual(AccountStatus.SUSPENDED,self.app.service.store.tenant_account(TENANT,first.user_id).status)
def test_provider_failure_retains_local_state_and_retry_recovers(self):
user=self.member();path=f'/admin/{TENANT}/users/{user.user_id}/status'
original=self.app.provisioning.tenant_access
def fail(**kwargs): raise RuntimeError('provider-secret')
self.app.provisioning.tenant_access=fail
response,body=self.post(path,status='suspended',confirmed=True)
self.assertEqual('502 Bad Gateway',response['status'])
self.assertNotIn(b'provider-secret',body)
self.assertEqual(AccountStatus.ACTIVE,self.app.service.store.tenant_account(TENANT,user.user_id).status)
self.app.provisioning.tenant_access=original
response,_=self.post(path,status='suspended',confirmed=True)
self.assertEqual('303 See Other',response['status'])
def test_concurrent_admin_suspensions_keep_one_active_admin(self):
from concurrent.futures import ThreadPoolExecutor
from threading import Barrier
first=self.member(role='tenant-admin',email='first@example.test')
second=self.member(role='tenant-admin',email='second@example.test')
barrier=Barrier(2)
def suspend(user):
barrier.wait(timeout=5)
return invoke(self.app,f'/api/v1/tenants/{TENANT}/users/{user.user_id}',method='PATCH',
cookie='ue_session=admin',body={'status':'suspended'},headers={'HTTP_IDEMPOTENCY_KEY':'concurrent-'+user.user_id})[0]['status']
with ThreadPoolExecutor(max_workers=2) as pool:
results=list(pool.map(suspend,[first,second]))
self.assertEqual(['200 OK','409 Conflict'],sorted(results))
self.assertEqual(1,len(self.app.provisioning.actions))
self.assertEqual(1,sum(self.app.service.store.tenant_account(TENANT,u.user_id).status==AccountStatus.ACTIVE for u in [first,second]))
def test_admin_succession_promote_then_demote_with_confirmation(self):
first=self.member(role='tenant-admin',email='first@example.test')
second=self.member(email='second@example.test')
path=f'/admin/{TENANT}/users/{first.user_id}/role'
response,_=self.post(path,role='user',confirmed=True)
self.assertEqual('409 Conflict',response['status'])
self.assertEqual([],self.app.provisioning.actions)
response,_=self.post(f'/admin/{TENANT}/users/{second.user_id}/role',role='tenant-admin',confirmed=True)
self.assertEqual('303 See Other',response['status'])
response,_=self.post(path,role='user',confirmed=True)
self.assertEqual('303 See Other',response['status'])
roles={m.user_id:m.kind for m in self.app.service.store.memberships_for_tenant(TENANT)}
self.assertEqual('tenant-admin',roles[second.user_id]);self.assertEqual('user',roles[first.user_id])
self.assertTrue(any(r.action=='membership.write' and r.subject==first.user_id for r in self.app.service.audit_records()))
def test_disabled_account_cannot_be_reactivated_by_password_setup(self):
user=self.member()
self.app.service.set_tenant_account_status(self.actor,user.user_id,AccountStatus.DISABLED,tenant=TENANT)
path=f'/admin/{TENANT}/users/{user.user_id}/provision'
response,_=self.post(path)
self.assertEqual('409 Conflict',response['status'])
response,_=invoke(self.app,f'/api/v1/tenants/{TENANT}/users/{user.user_id}/provision',method='POST',cookie='ue_session=admin',headers={'HTTP_IDEMPOTENCY_KEY':'setup-1234567890123456'})
self.assertEqual('409 Conflict',response['status'])
self.assertEqual([],self.app.provisioning.requests)
_,body=invoke(self.app,f'/admin/{TENANT}',cookie='ue_session=admin')
self.assertNotIn(b'Create password setup link',body)
self.assertIn(b'Reactivate',body)
def test_invitation_delivery_reports_queue_failure_and_adapter_acceptance(self):
response,_=self.post(f'/admin/{TENANT}/invitations',primary_email='invitee@example.test',display_name='Invitee',role='user')
self.assertEqual('303 See Other',response['status'])
event=next(e for e in self.app.service.store.outbox_history() if e.event_type=='family_member.invited')
_,body=invoke(self.app,f'/admin/{TENANT}',cookie='ue_session=admin')
self.assertIn(b'Queued for delivery',body)
self.app.service.store.save_outbox(replace(event,failed_at=utc_now(),failure_reason='secret detail'))
_,body=invoke(self.app,f'/admin/{TENANT}',cookie='ue_session=admin')
self.assertIn(b'Delivery failed',body);self.assertNotIn(b'secret detail',body)
self.app.service.store.save_outbox(replace(event,delivered_at=utc_now()))
_,body=invoke(self.app,f'/admin/{TENANT}',cookie='ue_session=admin')
self.assertIn(b'Accepted by delivery adapter; receipt by the person is unverified',body)
def test_invalid_role_cannot_create_partial_account(self):
before=len(self.app.service.store.users)
response,_=self.post(f'/admin/{TENANT}/users',display_name='Wrong role',primary_email='wrong@example.test',role='platform-operator')
self.assertEqual('400 Bad Request',response['status'])
self.assertEqual(before,len(self.app.service.store.users))
def test_audit_is_tenant_scoped_and_never_dumps_payload(self):
self.member();self.member(tenant=OTHER,email='private@example.test')
response,body=invoke(self.app,f'/admin/{TENANT}/activity',cookie='ue_session=admin')
self.assertEqual('200 OK',response['status'])
self.assertIn(b'Support reference',body)
self.assertNotIn(OTHER.encode(),body)
self.assertNotIn(b'private@example.test',body)
response,_=invoke(self.app,f'/admin/{OTHER}/activity',cookie='ue_session=admin')
self.assertEqual('403 Forbidden',response['status'])
class PlatformAdminJourneys(JourneyFixture):
def test_delivery_denial_redaction_retry_and_completed_guard(self):
event=OutboxEvent(event_id='delivery-test',event_type='family_invitation.created',aggregate_id='invitation',
tenant=TENANT,correlation_id='support-reference',payload={'private':'payload-secret'},
failed_at=utc_now(),failure_reason='credential-secret',dead_lettered_at=utc_now())
self.app.service.store.append_outbox(event)
for who in ['admin','member']:
response,_=invoke(self.app,'/platform/operations',cookie='ue_session='+who)
self.assertEqual('403 Forbidden',response['status'])
response,body=invoke(self.app,'/platform/operations',cookie='ue_session=operator',query='event_id=delivery-test')
self.assertEqual('200 OK',response['status'])
self.assertIn(b'Delivery stopped',body)
self.assertIn(b'Queue a retry',body)
for secret in [b'payload-secret',b'credential-secret']:self.assertNotIn(secret,body)
response,_=self.post('/platform/operations/replay',who='operator',event_id=event.event_id,csrf_token='wrong')
self.assertEqual('403 Forbidden',response['status'])
self.assertIsNotNone(self.app.service.store.outbox_event(event.event_id).failed_at)
response,_=self.post('/platform/operations/replay',who='operator',event_id=event.event_id)
self.assertEqual('303 See Other',response['status'])
queued=self.app.service.store.outbox_event(event.event_id)
self.assertIsNone(queued.failed_at);self.assertIsNone(queued.dead_lettered_at)
self.app.service.store.save_outbox(replace(queued,delivered_at=utc_now()))
response,_=self.post('/platform/operations/replay',who='operator',event_id=event.event_id)
self.assertEqual('409 Conflict',response['status'])
def test_partial_first_admin_setup_rolls_back_and_retry_finishes(self):
original=self.app.service.add_membership
before=len(self.app.service.store.users)
def fail(*args,**kwargs):raise RuntimeError('temporary membership failure')
self.app.service.add_membership=fail
form=dict(tenant='tenant:trial:new',display_name='New tenant',admin_email='first@example.test',admin_display_name='First')
response,_=self.post('/platform/tenants',who='operator',**form)
self.assertEqual('502 Bad Gateway',response['status'])
self.assertEqual(before,len(self.app.service.store.users))
self.assertIn(form['tenant'],self.app.tenant_management.records)
self.app.service.add_membership=original
response,_=self.post('/platform/tenants',who='operator',**form)
self.assertEqual('200 OK',response['status'])
self.assertEqual(1,len(self.app.service.store.memberships_for_tenant(form['tenant'])))
def test_bootstrap_retry_does_not_duplicate_first_admin(self):
form=dict(tenant='tenant:trial:new',display_name='New tenant',admin_email='first@example.test',admin_display_name='First')
for _ in range(2):
response,_=self.post('/platform/tenants',who='operator',**form)
self.assertEqual('200 OK',response['status'])
memberships=self.app.service.store.memberships_for_tenant(form['tenant'])
self.assertEqual(1,len(memberships))
self.assertEqual('tenant-admin',memberships[0].kind)
def test_recovery_uses_tenant_access_and_keeps_global_identity_operations_unused(self):
user=self.member()
response,_=self.post(f'/admin/{TENANT}/users/{user.user_id}/recover',who='operator',confirmed=True)
self.assertEqual('303 See Other',response['status'])
self.assertEqual('tenant_active',self.app.provisioning.actions[-1][0])
response,_=invoke(self.app,f'/api/v1/platform/tenants/{TENANT}/users/{user.user_id}/recover',
method='POST',cookie='ue_session=operator',headers={'HTTP_IDEMPOTENCY_KEY':'recover-1234567890'})
self.assertEqual('200 OK',response['status'])
self.assertTrue(all(a[0]=='tenant_active' for a in self.app.provisioning.actions))
def test_tenant_retirement_requires_confirmation_and_stale_confirmation_fails(self):
authority=self.app.tenant_management
authority.create_tenant(tenant=TENANT,display_name='Demo',idempotency_key='seed',correlation_id='seed')
path='/platform/tenants/'+quote(TENANT,safe='')
response,body=self.post(path,who='operator',operation='retire',version='1',reason='contract ended')
self.assertEqual('active',authority.records[TENANT].lifecycle)
token=self.confirm_token(body)
authority.records[TENANT]=replace(authority.records[TENANT],version=2)
response,_=self.post(path,who='operator',operation='retire',version='1',reason='contract ended',confirm_token=token)
self.assertEqual('409 Conflict',response['status'])
self.assertEqual('active',authority.records[TENANT].lifecycle)
response,_=self.post(path,who='operator',operation='retire',version='2',reason='contract ended',confirmed=True)
self.assertEqual('200 OK',response['status'])
self.assertEqual('retired',authority.records[TENANT].lifecycle)

View file

@ -97,6 +97,10 @@ class ProviderPostgresConformanceTests(unittest.TestCase):
def _store_factory(self) -> PostgresUserEngineStore:
assert self.config is not None
# This factory deliberately resets the shared disposable database. End
# earlier read transactions before rerunning DDL on a new connection.
for existing in self.connections:
existing.commit()
connection = connect_postgres_provider(self.config.dsn)
reset_user_engine_postgres_tables(connection)
self.connections.append(connection)

View file

@ -88,6 +88,7 @@ class PortalApplicationTests(unittest.TestCase):
login_url="https://kc.example/login",
)
self.claims = human_actor_claims(tenant="tenant:friendly:binky")
self.claims["roles"] = ["tenant-admin"]
def platform_claims(self):
claims = human_actor_claims(subject="platform-operator", tenant="platform:root")
@ -521,7 +522,7 @@ class PortalApplicationTests(unittest.TestCase):
body={
"display_name": "Ada Admin",
"primary_email": "ada@example.test",
"role": "tenant-admin",
"role": "user",
},
)
self.assertEqual("201 Created", created["status"])
@ -550,7 +551,7 @@ class PortalApplicationTests(unittest.TestCase):
)
self.assertEqual("200 OK", changed["status"])
self.assertEqual("suspended", json.loads(payload)["status"])
self.assertIn(("suspend", "ada"), self.app.provisioning.actions)
self.assertIn(("tenant_disabled", "ada", "tenant:friendly:binky"), self.app.provisioning.actions)
removed, payload = invoke_with_idempotency(
self.app,
f"/api/v1/tenants/tenant:friendly:binky/users/{user_id}",
@ -558,7 +559,7 @@ class PortalApplicationTests(unittest.TestCase):
)
self.assertEqual("200 OK", removed["status"])
self.assertEqual("removed", json.loads(payload)["status"])
self.assertIn(("deprovision", "ada"), self.app.provisioning.actions)
self.assertIn(("tenant_disabled", "ada", "tenant:friendly:binky"), self.app.provisioning.actions)
def test_invitation_lifecycle_is_versioned_and_replay_safe(self):
created, payload = invoke(
@ -874,7 +875,7 @@ class PortalApplicationTests(unittest.TestCase):
self.assertEqual("200 OK", renamed["status"])
self.assertIn(b"Renamed In Browser", html)
retired, html = invoke(
retired, html = invoke_confirmed(
self.app, f"/platform/tenants/{quoted}", method="POST",
cookie="ue_session=platform", form={
"csrf_token": "platform-csrf", "operation": "retire",
@ -886,7 +887,7 @@ class PortalApplicationTests(unittest.TestCase):
# A retired tenant offers no metadata form, matching the authority.
self.assertNotIn(b"Save metadata", html)
resubmitted, html = invoke(
resubmitted, html = invoke_confirmed(
self.app, f"/platform/tenants/{quoted}", method="POST",
cookie="ue_session=platform", form={
"csrf_token": "platform-csrf", "operation": "retire",
@ -976,7 +977,7 @@ class PortalApplicationTests(unittest.TestCase):
self.assertEqual("200 OK", admin_page["status"])
self.assertIn(b"Lifecycle diagnostics", html)
self.assertIn(b"Recover identity", html)
recovered, _ = invoke(
recovered, _ = invoke_confirmed(
self.app,
f"/admin/tenant:friendly:browser/users/{memberships[0].user_id}/recover",
method="POST", cookie="ue_session=platform",
@ -1150,7 +1151,7 @@ class PortalApplicationTests(unittest.TestCase):
"consent_accepted": "yes",
},
)
self.assertEqual("303 See Other", saved["status"])
self.assertEqual("200 OK", saved["status"])
user = self.app.service.store.user(invitation.user_id)
self.assertEqual("Updated Invitee", user.display_name)
self.assertEqual("invitee@example.test", user.primary_email)
@ -1202,6 +1203,10 @@ class FakeProvisioning:
password_setup_url=self.password_setup_url,
)
def tenant_access(self, *, external_subject, tenant, roles, enabled, idempotency_key, correlation_id):
self.actions.append(("tenant_active" if enabled else "tenant_disabled", external_subject, tenant))
return ProvisioningResult("netkingdom-lldap", external_subject, "tenant_active" if enabled else "tenant_disabled")
def suspend(self, *, external_subject, idempotency_key, correlation_id):
self.actions.append(("suspend", external_subject))
return ProvisioningResult("netkingdom-lldap", external_subject, "suspended")
@ -1340,3 +1345,13 @@ def invoke_with_idempotency(app, path, claims, *, method="POST", body=None):
if __name__ == "__main__":
unittest.main()
def invoke_confirmed(app, path, **kwargs):
from html import unescape
response, body = invoke(app, path, **kwargs)
token = re.search(rb'name="confirm_token" value="([^"]+)"', body)
if token:
kwargs['form'] = dict(kwargs['form'], confirm_token=unescape(token.group(1).decode()))
return invoke(app, path, **kwargs)
return response, body