user-engine/tests/test_journey_roles.py
tegwick 1127f852dd
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
Implement role-based account journeys with database and browser acceptance suites
Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a092fe-13b1-7f12-ac74-7d258af4d79c
2026-09-13 12:20:02 +02:00

287 lines
18 KiB
Python

"""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)