288 lines
18 KiB
Python
288 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'<script>',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)
|