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
This commit is contained in:
parent
75750c0036
commit
1127f852dd
24 changed files with 1554 additions and 148 deletions
291
tests/journey-coverage.json
Normal file
291
tests/journey-coverage.json
Normal 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": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
94
tests/test_journey_postgres.py
Normal file
94
tests/test_journey_postgres.py
Normal 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
287
tests/test_journey_roles.py
Normal 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'<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)
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue