diff --git a/.forgejo/workflows/journeys.yaml b/.forgejo/workflows/journeys.yaml new file mode 100644 index 0000000..204ce5d --- /dev/null +++ b/.forgejo/workflows/journeys.yaml @@ -0,0 +1,20 @@ +name: Account journey acceptance +on: + push: + branches: [main] + paths: ["src/**", "tests/**", "scripts/run_journey_suites.py", "Makefile", ".forgejo/workflows/journeys.yaml"] + workflow_dispatch: +jobs: + journeys: + runs-on: self-hosted + steps: + - name: Test the exact commit + run: | + set -eu + mkdir -p journey-source + curl -fsSL "https://forgejo.coulomb.social/${GITHUB_REPOSITORY}/archive/${GITHUB_SHA}.tar.gz" -o journey-source.tar.gz + tar xzf journey-source.tar.gz -C journey-source --strip-components=1 + cd journey-source + PYTHONDONTWRITEBYTECODE=1 make test + PYTHONDONTWRITEBYTECODE=1 make test-journeys JOURNEY_ARGS="--report journey-report.json" + cat journey-report.json diff --git a/Makefile b/Makefile index fa9bc65..6d22e4e 100644 --- a/Makefile +++ b/Makefile @@ -15,3 +15,14 @@ test-conformance: test-unit check-layer check-layer: PYTHONPATH=src $(PYTHON) scripts/check_layer_conformance.py --report + + +.PHONY: test-journeys +# Optional JOURNEY_ARGS supports --role, --report and --require-complete. +test-journeys: + PYTHONPATH=src:tests $(PYTHON) scripts/run_journey_suites.py $(JOURNEY_ARGS) + + +.PHONY: test-browser-journeys +test-browser-journeys: + PYTHONDONTWRITEBYTECODE=1 $(PYTHON) scripts/browser_journeys.py diff --git a/docs/account-journeys.md b/docs/account-journeys.md index 6bb94b4..3c9ead2 100644 --- a/docs/account-journeys.md +++ b/docs/account-journeys.md @@ -94,6 +94,20 @@ must be tested against controlled accounts; self-service credentials are not an administrative lookup credential. The portal help page is reachable before login so OTP-required login does not hide recovery. +## Executable implementation status + +Implementation workplans: USER-WP-0028 (users), USER-WP-0029 (tenant admins), +USER-WP-0030 (platform admins), USER-WP-0031 (automated suites), and NK-WP-0038 +(scoped provider lifecycle). The original review column above records the starting +gaps. For the current per-journey implementation state and executable tests, use +`tests/journey-coverage.json` and `make test-journeys`; the report never equates +provider-boundary tests with real OTP or mail acceptance. + +Now implemented: actual login-name handoff, profile validation/retry, scoped +provider lifecycle, role succession, cross-connection last-admin protection, +confirmation/stale-state checks, first-admin bootstrap rollback/retry, invitation +delivery readout, onboarding follow-up, tenant audit, and platform delivery retry. + ## Acceptance and remaining work USER-WP-0027 tracks the matrix and role-based usability gaps. KEY-WP-0035 tracks diff --git a/docs/journey-test-suites.md b/docs/journey-test-suites.md new file mode 100644 index 0000000..b6c4476 --- /dev/null +++ b/docs/journey-test-suites.md @@ -0,0 +1,50 @@ +# Automated journey acceptance + +Run `make test` for all regression and layer checks, then `make test-journeys` for +the role-based acceptance selection. Tests exercise WSGI routes, authorization, +CSRF, persisted records, provider failure/retry and confirmation state. They do +not send email or modify real directory users. + +Examples: + +```sh +make test-journeys JOURNEY_ARGS="--role user" +make test-journeys JOURNEY_ARGS="--role tenant_admin" +make test-journeys JOURNEY_ARGS="--role platform_admin" +make test-journeys JOURNEY_ARGS="--report /tmp/user-engine-journeys.json" +make test-journeys JOURNEY_ARGS="--require-complete" +``` + +The last command is deliberately a release-completeness gate: it fails while any +journey still has an implementation or external acceptance gap. A local suite +passing is distinct from all 29 journeys being complete. `tests/journey-coverage.json` +contains every journey ID, executable test selectors, implementation status and +named remaining work. In particular, OTP help tests do not claim provider OTP +activation coverage. KeyCape's Go suite and NetKingdom's provider suite are +separate owner checks. + +CI `.forgejo/workflows/journeys.yaml` runs full regression and journey selection +against the exact pushed commit, and prints the JSON report. All fake identities, +provider failure controls and test sessions live in tests, not production routes. + +Tenant lifecycle and role changes hold an in-memory lock or tenant-scoped +PostgreSQL advisory lock across provider calls and local mutation. Tests cover +concurrent administrator suspension with independent database connections, lock +release on exception and preservation of caller transactions. Nested first-admin +bootstrap transactions roll back all local records after a failure. Directory +changes and local state still are not one database transaction: desired-state +retry/readback is required after a crash between external success and local save. + +`make test-browser-journeys` starts a loopback-only synthetic portal and isolated +Chromium profile, then drives user/admin/operator navigation and confirmation +with Node's native CDP client. Set `JOURNEY_CHROME` if Chromium cannot be found. +Missing browser dependencies fail explicitly; no test login endpoints are added +to the production application. The ordinary CI job runs the WSGI suites; the +browser command can also run on a runner provisioned with Node and Chromium. + +Database suite: `tests/test_journey_postgres.py` uses the existing +`USER_ENGINE_POSTGRES_TEST_DSN` plus `USER_ENGINE_POSTGRES_TEST_RESET=1` contract. +Use only a disposable database: conformance tests reset user-engine tables. +The 2026-09-13 run used a fresh local Docker PostgreSQL container and completed +all 210 regression tests with zero skips. The normal dependency-free run skips +seven opt-in PostgreSQL tests. See the rollout evidence for exact commands. diff --git a/scripts/browser_journeys.mjs b/scripts/browser_journeys.mjs new file mode 100644 index 0000000..fa6847b --- /dev/null +++ b/scripts/browser_journeys.mjs @@ -0,0 +1,51 @@ +// No production accounts, network providers or header overrides are used. +const [debug, base] = process.argv.slice(2); +const version=await (await fetch(debug+'/json/version')).json(); +const ws=new WebSocket(version.webSocketDebuggerUrl); +await new Promise(resolve=>ws.addEventListener('open',resolve,{once:true})); +let next=0;const pending=new Map(); +ws.addEventListener('message',event=>{const m=JSON.parse(event.data);if(pending.has(m.id)){const p=pending.get(m.id);pending.delete(m.id);m.error?p.reject(Error(m.error.message)):p.resolve(m.result);}}); +const call=(method,params={},sessionId)=>new Promise((resolve,reject)=>{const id=++next;pending.set(id,{resolve,reject});ws.send(JSON.stringify({id,method,params,...(sessionId?{sessionId}:{})}));}); +const {targetId}=await call('Target.createTarget',{url:'about:blank'}); +const {sessionId}=await call('Target.attachToTarget',{targetId,flatten:true}); +const cmd=(method,params)=>call(method,params,sessionId); +await cmd('Page.enable');await cmd('Network.enable'); +const evaluate=async expression=>{const r=await cmd('Runtime.evaluate',{expression,returnByValue:true});if(r.exceptionDetails)throw Error('Browser evaluation failed');return r.result.value;}; +async function waitFor(expression){for(let n=0;n<70;n++){if(await evaluate(expression))return;await new Promise(r=>setTimeout(r,100));}throw Error('Browser condition not met: '+expression);} +async function navigate(path){await cmd('Page.navigate',{url:base+path});await waitFor('location.href === '+JSON.stringify(base+path)+' && document.readyState === "complete"');} +async function identity(who){await cmd('Network.clearBrowserCookies');if(who)await cmd('Network.setCookie',{name:'ue_session',value:who,url:base,path:'/',httpOnly:true,sameSite:'Lax'});} +let checks=0;async function check(expression,name){if(!await evaluate(expression))throw Error(name);console.log('PASS '+name);checks++;} +try{ + await navigate('/'); + await check(`!!document.querySelector('a[href="/login"]') && !document.querySelector('a[href="/logout"]')`,'U01 anonymous session controls'); + await identity('member');await navigate('/onboarding'); + await check(`!!document.querySelector('a[href="/logout"]') && !document.querySelector('a[href="/login"]')`,'U01 authenticated session controls'); + await navigate('/platform'); + await check(`document.body.innerText.includes("Access is not available") && !!document.querySelector('a[href="/access-recovery"]')`,'T01 member denial has recovery'); + await navigate('/security'); + await check(`document.body.innerText.includes("Authenticator setup is temporarily unavailable")`,'U06 unavailable OTP is explicit'); + await identity('admin');await navigate('/admin/tenant:trial:demo-company'); + await check(`document.body.innerText.includes("Login name: actual.login")`,'T03 actual login name shown'); + await check(`document.body.innerText.includes("Onboarding follow-up")`,'T07 onboarding state visible'); + await evaluate(`Array.from(document.forms).find(f=>f.action.endsWith("/status")).querySelector("button").click()`); + await waitFor('document.body.innerText.includes("Confirm change")'); + await check(`document.body.innerText.includes("Other tenant access and the shared login are retained")`,'T06 scope confirmation'); + await evaluate(`Array.from(document.links).find(a=>a.textContent==="Cancel without changes").click()`); + await waitFor('location.pathname === "/"'); + await navigate('/admin/tenant:trial:demo-company'); + await check(`document.body.innerText.includes("active for this tenant")`,'T06 cancel preserves active account'); + await cmd('Emulation.setDeviceMetricsOverride',{width:390,height:844,deviceScaleFactor:1,mobile:true}); + await navigate('/security'); + await check(`document.documentElement.scrollWidth <= innerWidth`,'U12 security recovery fits mobile'); + await cmd('Emulation.clearDeviceMetricsOverride'); + await identity('operator');await navigate('/platform'); + await check(`!!document.querySelector('a[href="/platform/operations"]')`,'P01 platform recovery navigation'); + await navigate('/platform/operations'); + await check(`document.body.innerText.includes("Live sign-in, email receipt and authenticator health are not verified here")`,'P05 unknown provider health remains explicit'); + await navigate('/logout'); + await check(`document.body.innerText.includes("Log out of this portal?")`,'U11 logout requires confirmation'); + await evaluate(`document.querySelector('form[action="/logout"] button').click()`); + await waitFor('location.pathname === "/logged-out"'); + await check(`!!document.querySelector('a[href="/login"]') && !document.querySelector('a[href="/logout"]')`,'U11 logout updates controls'); + console.log(JSON.stringify({checks,result:'passed',scope:'isolated browser and synthetic providers; live OTP/email not inferred'})); +}finally{await call('Target.closeTarget',{targetId});ws.close();} diff --git a/scripts/browser_journeys.py b/scripts/browser_journeys.py new file mode 100644 index 0000000..381e6c8 --- /dev/null +++ b/scripts/browser_journeys.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 +"""Run Chromium against an isolated loopback-only portal with synthetic identities.""" +from pathlib import Path +import os +import shutil +import subprocess +import sys +import tempfile +from threading import Thread +import time +from wsgiref.simple_server import make_server, WSGIRequestHandler + +ROOT=Path(__file__).resolve().parents[1] +sys.path[:0]=[str(ROOT/'src'),str(ROOT/'tests')] +from test_journey_roles import JourneyFixture + +chrome=os.environ.get('JOURNEY_CHROME') or shutil.which('chromium') or shutil.which('google-chrome') +if not chrome: + matches=sorted((Path.home()/'.cache/ms-playwright').glob('chromium-*/chrome-linux64/chrome')) + chrome=str(matches[-1]) if matches else None +if not chrome or not shutil.which('node'): + raise SystemExit('Chromium and Node are required; set JOURNEY_CHROME to the Chromium executable. Browser tests were not run.') +fixture=JourneyFixture();fixture.setUp();fixture.member(email='actual.login@example.test') +class Quiet(WSGIRequestHandler): + def log_message(self,*args):pass +server=make_server('127.0.0.1',0,fixture.app,handler_class=Quiet) +worker=Thread(target=server.serve_forever,daemon=True);worker.start() +try: + with tempfile.TemporaryDirectory(prefix='user-engine-browser-') as profile: + process=subprocess.Popen([chrome,'--headless','--no-sandbox','--disable-gpu','--remote-debugging-address=127.0.0.1', + '--remote-debugging-port=0','--user-data-dir='+profile,'about:blank'],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL) + try: + port_file=Path(profile)/'DevToolsActivePort' + for _ in range(100): + if port_file.exists():break + if process.poll() is not None:raise RuntimeError('Chromium exited before test connection') + time.sleep(.1) + if not port_file.exists():raise RuntimeError('Chromium did not expose its test connection') + port=port_file.read_text().splitlines()[0] + subprocess.run(['node',str(ROOT/'scripts/browser_journeys.mjs'),'http://127.0.0.1:'+port, + 'http://127.0.0.1:'+str(server.server_port)],check=True,timeout=55) + finally: + process.terminate() + try:process.wait(timeout=5) + except subprocess.TimeoutExpired:process.kill();process.wait() +finally: + server.shutdown();server.server_close();worker.join(timeout=5) diff --git a/scripts/run_journey_suites.py b/scripts/run_journey_suites.py new file mode 100644 index 0000000..5768e9b --- /dev/null +++ b/scripts/run_journey_suites.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +"""Run mapped acceptance tests; report external gaps separately from test results.""" +import argparse +import json +from pathlib import Path +import sys +import unittest + +ROOT=Path(__file__).resolve().parents[1] +sys.path[:0]=[str(ROOT/'src'),str(ROOT/'tests')] +parser=argparse.ArgumentParser() +parser.add_argument('--role',choices=['user','tenant_admin','platform_admin']) +parser.add_argument('--report',type=Path) +parser.add_argument('--require-complete',action='store_true',help='Fail while any journey has an implementation or external acceptance gap') +args=parser.parse_args() +rows=json.loads((ROOT/'tests/journey-coverage.json').read_text())['journeys'] +expected={f'U{i:02}' for i in range(1,14)}|{f'T{i:02}' for i in range(1,9)}|{f'P{i:02}' for i in range(1,9)} +if len(rows)!=29 or {r['id'] for r in rows}!=expected: + raise SystemExit('Journey coverage must contain each of the 29 journey IDs exactly once') +rows=[r for r in rows if not args.role or r['role']==args.role] +for row in rows: + if not row['tests'] or row['implementation'] not in {'implemented','partial','external-blocked'}: + raise SystemExit('Invalid coverage entry: '+row['id']) + if row['implementation']!='implemented' and not row['remaining']: + raise SystemExit('Unexplained journey gap: '+row['id']) +selectors=sorted({name for row in rows for name in row['tests']}) +suite=unittest.TestSuite(unittest.defaultTestLoader.loadTestsFromName(name) for name in selectors) +result=unittest.TextTestRunner(verbosity=2).run(suite) +failed={test.id() for test,_ in result.failures+result.errors} +skipped={test.id() for test,_ in result.skipped} +report={'tests_run':result.testsRun,'test_success':result.wasSuccessful(),'skipped':len(skipped), + 'journeys':[dict(row,automated_result='failed' if failed.intersection(row['tests']) else 'skipped' if skipped.intersection(row['tests']) else 'passed') for row in rows], + 'complete':result.wasSuccessful() and not skipped and all(r['implementation']=='implemented' for r in rows)} +if args.report: + args.report.parent.mkdir(parents=True,exist_ok=True) + args.report.write_text(json.dumps(report,indent=2)+'\n') +print(json.dumps({'tests_run':report['tests_run'],'test_success':report['test_success'],'complete':report['complete'], + 'unresolved_journeys':[r['id'] for r in rows if r['implementation']!='implemented']})) +raise SystemExit(0 if result.wasSuccessful() and not skipped and (not args.require_complete or report['complete']) else 1) diff --git a/src/user_engine/adapters/local.py b/src/user_engine/adapters/local.py index dbb7ca6..927d652 100644 --- a/src/user_engine/adapters/local.py +++ b/src/user_engine/adapters/local.py @@ -4,6 +4,7 @@ from __future__ import annotations import copy import os +from threading import RLock from contextlib import contextmanager from dataclasses import dataclass, field from typing import Iterable, Iterator, Mapping, cast @@ -78,6 +79,17 @@ class InMemoryUserEngineStore: default=None, init=False, repr=False ) + _lifecycle_lock: object = field(default_factory=RLock, repr=False, compare=False) + + @contextmanager + def tenant_lifecycle_guard(self, tenant: str) -> Iterator[None]: + # One reentrant lock also protects the in-memory transaction snapshot. + with self._lifecycle_lock: + yield + + def outbox_history(self) -> tuple[OutboxEvent, ...]: + return tuple(self.outbox_events) + def migrate(self) -> None: """Apply the standalone schema migration manifest.""" self.schema_version = SCHEMA_VERSION diff --git a/src/user_engine/adapters/postgres.py b/src/user_engine/adapters/postgres.py index c76f243..075946e 100644 --- a/src/user_engine/adapters/postgres.py +++ b/src/user_engine/adapters/postgres.py @@ -8,6 +8,7 @@ pooling, securing, and observing those connections. from __future__ import annotations import json +from threading import RLock from contextlib import contextmanager from importlib.resources import files from typing import Any, Iterable, Iterator, Mapping, Protocol, cast @@ -80,6 +81,32 @@ class PostgresUserEngineStore: def __init__(self, connection: PostgresConnection) -> None: self.connection = connection + self._lifecycle_lock = RLock() + self._transaction_depth = 0 + self._transaction_failed = False + + @contextmanager + def tenant_lifecycle_guard(self, tenant: str) -> Iterator[None]: + # Session locks survive the service's local commits and cover provider + # side effects. Separate processes use the same tenant-scoped DB lock. + with self._lifecycle_lock: + key = "user-engine:tenant-lifecycle:" + tenant + with self._cursor() as cursor: + cursor.execute("SELECT pg_advisory_lock(hashtextextended(%s, 0))", (key,)) + try: + yield + except BaseException: + # An aborted transaction cannot execute the unlock query. + self.connection.rollback() + raise + finally: + with self._cursor() as cursor: + cursor.execute("SELECT pg_advisory_unlock(hashtextextended(%s, 0))", (key,)) + + def outbox_history(self) -> tuple[OutboxEvent, ...]: + with self._cursor() as cursor: + cursor.execute("SELECT payload FROM user_engine_outbox_events ORDER BY occurred_at, event_id") + return tuple(cast(OutboxEvent, self._decode_payload_row("outbox_events", row)) for row in cursor.fetchall()) @property def schema_version(self) -> str | None: @@ -97,16 +124,26 @@ class PostgresUserEngineStore: @contextmanager def transaction(self) -> Iterator[None]: - begin = getattr(self.connection, "begin", None) - if callable(begin): - begin() + outer = self._transaction_depth == 0 + if outer: + self._transaction_failed = False + begin = getattr(self.connection, "begin", None) + if callable(begin): begin() + self._transaction_depth += 1 try: yield - except Exception: - self.connection.rollback() + except BaseException: + self._transaction_failed = True + if outer: self.connection.rollback() raise else: - self.connection.commit() + if outer: + if self._transaction_failed: + self.connection.rollback() + raise RuntimeError("nested transaction failed") + self.connection.commit() + finally: + self._transaction_depth -= 1 def save_user(self, user: User) -> None: self._upsert_record(user) diff --git a/src/user_engine/adapters/provisioning.py b/src/user_engine/adapters/provisioning.py index 237ae41..2532c60 100644 --- a/src/user_engine/adapters/provisioning.py +++ b/src/user_engine/adapters/provisioning.py @@ -39,6 +39,14 @@ class HTTPIdentityProvisioningAdapter: "preferred_username": request.preferred_username, }) + def tenant_access(self, *, external_subject: str, tenant: str, roles: tuple[str, ...], + enabled: bool, idempotency_key: str, correlation_id: str) -> ProvisioningResult: + return self._post("/v1/identities/tenant-access", { + "external_subject": external_subject, "tenant": tenant, "roles": roles, + "enabled": enabled, "idempotency_key": idempotency_key, + "correlation_id": correlation_id, + }) + def suspend(self, *, external_subject: str, idempotency_key: str, correlation_id: str) -> ProvisioningResult: return self._lifecycle("suspend", external_subject, idempotency_key, correlation_id) diff --git a/src/user_engine/ports.py b/src/user_engine/ports.py index 9ceef94..67b8565 100644 --- a/src/user_engine/ports.py +++ b/src/user_engine/ports.py @@ -175,6 +175,12 @@ class IdentityProvisioningPort(Protocol): def provision(self, request: ProvisioningRequest) -> ProvisioningResult: """Create or resume an external login identity.""" + def tenant_access( + self, *, external_subject: str, tenant: str, roles: tuple[str, ...], + enabled: bool, idempotency_key: str, correlation_id: str, + ) -> ProvisioningResult: + """Change only this tenant's directory groups, preserving identity and other tenants.""" + def suspend( self, *, external_subject: str, idempotency_key: str, correlation_id: str ) -> ProvisioningResult: @@ -422,6 +428,12 @@ class UserEngineStore(Protocol): def append_outbox(self, event: OutboxEvent) -> None: """Append an outbox event.""" + def tenant_lifecycle_guard(self, tenant: str): + """Serialize a tenant's lifecycle/role changes across provider and local writes.""" + + def outbox_history(self) -> tuple[OutboxEvent, ...]: + """Return delivery records including failed and completed attempts.""" + def pending_outbox(self) -> tuple[OutboxEvent, ...]: """Return pending outbox events in write order.""" diff --git a/src/user_engine/service.py b/src/user_engine/service.py index 01e6379..a945fa3 100644 --- a/src/user_engine/service.py +++ b/src/user_engine/service.py @@ -1837,7 +1837,69 @@ class UserEngineService: ) return updated - def set_tenant_account_status( + def authorize_tenant_member_action( + self, actor: Actor, user_id: str, *, tenant: str, correlation_id: str, + ) -> User: + """Authorize before any credential-provider side effect or target readout.""" + self.resolve_tenant_context(actor, tenant) + if not {"tenant-admin", PLATFORM_OPERATOR_ROLE}.intersection(actor.roles): + raise AuthorizationDenied("tenant administrator role required") + memberships = self.store.memberships_for_user(user_id, tenant=tenant) + if not any(m.scope_type == "tenant" and m.scope_id == tenant for m in memberships): + raise NotFoundError("account is not a member of this tenant") + self._authorize(actor, action="tenant.account.update", + resource_type="user-engine:tenant-account", + resource_id=f"{tenant}:{user_id}", tenant=tenant, + correlation_id=correlation_id, target_user_id=user_id) + return self._require_user(user_id) + + def validate_tenant_role_change(self, actor: Actor, user_id: str, role: str, *, tenant: str, correlation_id: str) -> None: + self.authorize_tenant_member_action(actor, user_id, tenant=tenant, correlation_id=correlation_id) + if role not in {"user", "tenant-admin"}: + raise ValidationError("Choose User or Tenant administrator.") + self._authorize(actor, action="membership.write", resource_type="user-engine:membership", + resource_id=f"{tenant}:{user_id}:tenant:{tenant}", tenant=tenant, + correlation_id=correlation_id, target_user_id=user_id, + context={"scope_type":"tenant", "scope_id":tenant, "kind":role}) + if role != "tenant-admin": + self.require_admin_successor(user_id, tenant=tenant) + + def set_tenant_role(self, actor: Actor, user_id: str, role: str, *, tenant: str, correlation_id: str) -> None: + with self.store.tenant_lifecycle_guard(tenant): + self._set_tenant_role(actor, user_id, role, tenant=tenant, correlation_id=correlation_id) + + def _set_tenant_role(self, actor: Actor, user_id: str, role: str, *, tenant: str, correlation_id: str) -> None: + self.validate_tenant_role_change(actor, user_id, role, tenant=tenant, correlation_id=correlation_id) + with self.store.transaction(): + for membership in self.store.memberships_for_user(user_id, tenant=tenant): + if membership.scope_type == "tenant" and membership.scope_id == tenant: + self.store.save_membership(replace(membership, kind=role, freshness_version=correlation_id)) + self._record_mutation(actor, action="membership.write", subject=user_id, tenant=tenant, + correlation_id=correlation_id, decision_id=None, + event_type="membership.role_changed", aggregate_id=user_id, + payload={"user_id":user_id, "tenant":tenant, "role":role}) + + def require_admin_successor(self, user_id: str, *, tenant: str) -> None: + """Do not deactivate the last active tenant administrator.""" + members = self.store.memberships_for_tenant(tenant) + admins = {m.user_id for m in members if m.scope_type == "tenant" + and m.scope_id == tenant and m.kind == "tenant-admin"} + if user_id not in admins: + return + account = self.store.tenant_account(tenant, user_id) + if account is not None and account.status != AccountStatus.ACTIVE: + return + for other in admins - {user_id}: + state = self.store.tenant_account(tenant, other) + if state is not None and state.status == AccountStatus.ACTIVE: + return + raise ConflictError("Assign another active tenant administrator before disabling this account.") + + def set_tenant_account_status(self, actor: Actor, user_id: str, status: AccountStatus, *, tenant: str, correlation_id: str | None = None) -> TenantAccount: + with self.store.tenant_lifecycle_guard(tenant): + return self._set_tenant_account_status(actor, user_id, status, tenant=tenant, correlation_id=correlation_id) + + def _set_tenant_account_status( self, actor: Actor, user_id: str, @@ -1863,6 +1925,8 @@ class UserEngineService: ) updated = replace(account, status=status) with self.store.transaction(): + if status in {AccountStatus.SUSPENDED, AccountStatus.DISABLED}: + self.require_admin_successor(user_id, tenant=tenant_context.tenant) self.store.save_tenant_account(updated) self._record_mutation( actor, diff --git a/src/user_engine/testing/postgres_provider.py b/src/user_engine/testing/postgres_provider.py index 3572a9d..c9f5074 100644 --- a/src/user_engine/testing/postgres_provider.py +++ b/src/user_engine/testing/postgres_provider.py @@ -30,7 +30,7 @@ def postgres_provider_test_config( environ: Mapping[str, str] | None = None, ) -> tuple[PostgresProviderTestConfig | None, str | None]: """Return live test config or a skip reason.""" - env = environ or os.environ + env = os.environ if environ is None else environ dsn = env.get(POSTGRES_TEST_DSN_ENV, "").strip() if not dsn: return None, f"{POSTGRES_TEST_DSN_ENV} is not set" diff --git a/src/user_engine/web.py b/src/user_engine/web.py index 5cac56b..fdde662 100644 --- a/src/user_engine/web.py +++ b/src/user_engine/web.py @@ -13,6 +13,7 @@ from contextvars import ContextVar from dataclasses import asdict, is_dataclass, replace from enum import Enum from html import escape +import time import hashlib import hmac import json @@ -349,6 +350,8 @@ class PortalApplication: actor = self._actor(environ) self._set_account_navigation(environ, actor) + if path.startswith("/api/v1/tenants/"): + self._require_tenant_admin(actor, path.split("/")[4]) if path == "/api/v1/me" and method == "GET": return self._json(start_response, "200 OK", _jsonable(self.service.me(self._claims(environ), correlation_id=correlation_id)), correlation_id) if path == "/api/v1/me/profile" and method == "PATCH": @@ -383,13 +386,25 @@ class PortalApplication: body = self._form_body(environ) self._require_csrf(environ, str(body.get("csrf_token", ""))) self.service.me(self._claims(environ), correlation_id=correlation_id) - self.service.update_self_service_profile( - actor, display_name=str(body.get("display_name", "")), - consent_accepted=body.get("consent_accepted") == "yes", - consent_version="portal-terms-v1", - correlation_id=correlation_id, - ) - return self._redirect(start_response, "/onboarding", correlation_id) + try: + self.service.update_self_service_profile( + actor, display_name=str(body.get("display_name", "")), + consent_accepted=body.get("consent_accepted") == "yes", + consent_version="portal-terms-v1", correlation_id=correlation_id, + ) + except ValidationError: + name = escape(str(body.get("display_name", ""))[:201]) + csrf = escape(self._csrf_token(environ)) + checked = " checked" if body.get("consent_accepted") == "yes" else "" + page = self._page_html("Check your profile", f'''

Check your profile

+

Enter a display name of 1 to 200 characters. Your profile has not been saved.

+
+ + +

Cancel

''') + return self._html(start_response, page, correlation_id, status="400 Bad Request") + return self._html(start_response, self._page_html("Profile saved", + '

Profile saved

Your profile changes have been saved.

Return to my account

'), correlation_id) if path.startswith("/onboarding/") and "/steps/" in path and path.endswith("/complete") and method == "POST": body = self._form_body(environ) self._require_csrf(environ, str(body.get("csrf_token", ""))) @@ -511,6 +526,7 @@ class PortalApplication: raise ValidationError("identity provisioning is unavailable") parts = path.split("/") tenant, user_id = parts[5], parts[7] + self.service.authorize_tenant_member_action(actor, user_id, tenant=tenant, correlation_id=correlation_id) idempotency_key = self._idempotency_key(environ) user = self.service.store.user(user_id) if user is None: @@ -531,10 +547,9 @@ class PortalApplication: ) recovery = {"status": provisioned.status, "changed": ("identity",)} else: - reconciled = self.provisioning.reconcile( - request, external_subject=identity.subject, desired_status="active" - ) - recovery = {"status": reconciled.status, "drift": reconciled.drift, "changed": reconciled.changed} + self._change_status(actor, tenant, user_id, AccountStatus.ACTIVE, + idempotency_key=idempotency_key, correlation_id=correlation_id) + recovery = {"status": "tenant_active", "changed": ("tenant_access",)} account = self.service.set_tenant_account_status( actor, user_id, AccountStatus.ACTIVE, tenant=tenant, correlation_id=correlation_id, @@ -654,7 +669,8 @@ class PortalApplication: if path.startswith("/api/v1/tenants/") and "/invitations/" in path and method == "POST": parts = path.split("/") tenant, invitation_id, action = parts[4], parts[6], parts[7] - self.service.resolve_tenant_context(actor, tenant) + self._require_tenant_admin(actor, tenant) + self._require_invitation_tenant(invitation_id, tenant) expected = self._expected_version(environ) if action == "resend": value = self.service.resend_family_invitation( @@ -674,10 +690,11 @@ class PortalApplication: raise ValidationError("identity provisioning is unavailable") parts = path.split("/") tenant, user_id = parts[4], parts[6] - self.service.resolve_tenant_context(actor, tenant) + self.service.authorize_tenant_member_action(actor, user_id, tenant=tenant, correlation_id=correlation_id) user = self.service.store.user(user_id) if user is None: raise NotFoundError("user not found") + self._require_setup_access(tenant, user_id) idempotency_key = str(environ.get("HTTP_IDEMPOTENCY_KEY", "")) if len(idempotency_key) < 16: raise ValidationError("Idempotency-Key must contain at least 16 characters") @@ -730,21 +747,29 @@ class PortalApplication: idempotency_key = str(environ.get("HTTP_IDEMPOTENCY_KEY", "")) if len(idempotency_key) < 16: raise ValidationError("Idempotency-Key must contain at least 16 characters") - identity = next(iter(self.service.store.identities_for_user(user_id)), None) - if identity is not None: - self.provisioning.deprovision( - external_subject=identity.subject, - idempotency_key=idempotency_key, - correlation_id=correlation_id, - ) - account = self.service.set_tenant_account_status( - actor, user_id, AccountStatus.DISABLED, - tenant=tenant, correlation_id=correlation_id, - ) + account = self._change_status(actor, tenant, user_id, AccountStatus.DISABLED, + idempotency_key=idempotency_key, correlation_id=correlation_id) return self._json(start_response, "200 OK", { "status": "removed", "tenant_account": _jsonable(account), - "provider_identity_removed": identity is not None, + "provider_identity_removed": False, }, correlation_id) + if path in {"/platform/operations", "/platform/operations/replay"}: + self.service.resolve_tenant_context(actor, PLATFORM_TENANT) + if method == "POST" and path.endswith("/replay"): + body = self._form_body(environ) + self._require_csrf(environ, str(body.get("csrf_token", ""))) + event = self.service.store.outbox_event(str(body.get("event_id", ""))) + if event is None: + raise NotFoundError("delivery record not found") + if event.delivered_at is not None or event.claimed_by: + raise ConflictError("Delivery is already completed or being processed. Refresh its status.") + self.service.replay_outbox(actor, event.event_id, correlation_id=correlation_id) + return self._redirect(start_response, "/platform/operations?"+urlencode({"event_id":event.event_id}), correlation_id) + if method != "GET" or path.endswith("/replay"): + raise NotFoundError("operations route not found") + query = parse_qs(str(environ.get("QUERY_STRING", ""))) + return self._html(start_response, self._operations_page(actor, + self._csrf_token(environ), query.get("event_id", [""])[0], correlation_id), correlation_id) if path == "/platform" and method == "GET": self.service.resolve_tenant_context(actor, PLATFORM_TENANT) return self._html( @@ -764,19 +789,26 @@ class PortalApplication: idempotency_key=f"portal-tenant-{tenant}", correlation_id=correlation_id, ) email = str(body.get("admin_email", "")) - if email: - user = self.service.create_user( - actor, display_name=body.get("admin_display_name"), - primary_email=email, correlation_id=correlation_id, - ) - self.service.set_tenant_account_status( - actor, user.user_id, AccountStatus.INVITED, - tenant=tenant, correlation_id=correlation_id, - ) - self.service.add_membership( - actor, user.user_id, tenant=tenant, scope_type="tenant", - scope_id=tenant, kind="tenant-admin", correlation_id=correlation_id, - ) + with self.service.store.tenant_lifecycle_guard(tenant), self.service.store.transaction(): + existing_admin = any( + m.scope_type == "tenant" and m.scope_id == tenant and m.kind == "tenant-admin" + and (u := self.service.store.user(m.user_id)) is not None + and (u.primary_email or "").casefold() == email.casefold() + for m in self.service.store.memberships_for_tenant(tenant) + ) if email else False + if email and not existing_admin: + user = self.service.create_user( + actor, display_name=body.get("admin_display_name"), + primary_email=email, correlation_id=correlation_id, + ) + self.service.set_tenant_account_status( + actor, user.user_id, AccountStatus.INVITED, + tenant=tenant, correlation_id=correlation_id, + ) + self.service.add_membership( + actor, user.user_id, tenant=tenant, scope_type="tenant", + scope_id=tenant, kind="tenant-admin", correlation_id=correlation_id, + ) return self._html( start_response, self._platform_result(result, tenant, bool(email)), correlation_id, @@ -834,6 +866,13 @@ class PortalApplication: version = str(body.get("version", "")) if not version.isdigit(): raise ValidationError("the current record version is required") + if operation in {"retire", "reactivate"}: + current = self.tenant_management.tenant(tenant=tenant, correlation_id=correlation_id) + preview = self._confirm_change(environ, start_response, body, str(current.version), + f"{operation.capitalize()} {tenant}", + "This changes the tenant lifecycle. Existing application sessions may take time to reflect the change. Review the tenant and reason before confirming.", correlation_id) + if preview is not None: + return preview metadata = { key: str(body[key]) for key in ("display_name", "contact_email") if str(body.get(key, "")).strip() @@ -853,8 +892,13 @@ class PortalApplication: correlation_id, ) if path.startswith("/admin/") and method == "GET": - tenant = path.split("/")[2] - self.service.resolve_tenant_context(actor, tenant) + tenant = unquote(path.split("/")[2]) + self._require_tenant_admin(actor, tenant) + if path.endswith("/activity"): + self.service.tenant_diagnostics(actor, tenant=tenant, correlation_id=correlation_id) + return self._html(start_response, self._audit_page(tenant), correlation_id) + if len(path.split("/")) != 3: + raise NotFoundError("tenant page not found") memberships = self.service.store.memberships_for_tenant(tenant) invitations = self.service.store.family_invitations_for_tenant(tenant) diagnostics = self.service.tenant_diagnostics( @@ -870,10 +914,23 @@ class PortalApplication: ) if path.startswith("/admin/") and method == "POST": parts = path.split("/") - tenant = parts[2] - self.service.resolve_tenant_context(actor, tenant) + tenant = unquote(parts[2]) + self._require_tenant_admin(actor, tenant) + if len(parts) == 6 and parts[3] == "users": + self.service.authorize_tenant_member_action(actor, parts[4], tenant=tenant, correlation_id=correlation_id) body = self._form_body(environ) self._require_csrf(environ, str(body.get("csrf_token", ""))) + if len(parts) == 6 and parts[3] == "users" and parts[5] in {"status", "remove", "recover", "role"}: + user = self.service.store.user(parts[4]) + state = self.service.store.tenant_account(tenant, parts[4]) + snapshot = repr((state, self.service.store.memberships_for_user(parts[4], tenant=tenant))) + preview = self._confirm_change(environ, start_response, body, snapshot, + f"{parts[5].capitalize()} account in {tenant}", + f"Account: {user.display_name or user.user_id}. This action applies to this tenant. Other tenant access and the shared login are retained.", correlation_id) + if preview is not None: + return preview + if len(parts) == 4 and parts[3] in {"users", "invitations"} and body.get("role", "user") not in {"user", "tenant-admin"}: + raise ValidationError("Choose User or Tenant administrator.") if len(parts) == 4 and parts[3] == "users": user = self.service.create_user( actor, @@ -904,6 +961,7 @@ class PortalApplication: return self._redirect(start_response, f"/admin/{tenant}", correlation_id) if len(parts) == 6 and parts[3] == "invitations": invitation_id, action = parts[4], parts[5] + self._require_invitation_tenant(invitation_id, tenant) version = int(body.get("version", "0")) if action == "resend": self.service.resend_family_invitation( @@ -925,6 +983,7 @@ class PortalApplication: user = self.service.store.user(user_id) if user is None: raise NotFoundError("user not found") + self._require_setup_access(tenant, user_id) result = self.provisioning.provision(ProvisioningRequest( user_id=user.user_id, tenant=tenant, @@ -947,12 +1006,29 @@ class PortalApplication: return self._html( start_response, self._password_setup_handoff( - result.password_setup_url, tenant + result.password_setup_url, tenant, result.external_subject ), correlation_id, ) query = urlencode({"provisioned": user.user_id, "status": result.status}) return self._redirect(start_response, f"/admin/{tenant}?{query}", correlation_id) + if len(parts) == 6 and parts[3] == "users" and parts[5] == "role": + role = str(body.get("role", "")) + user_id = parts[4] + with self.service.store.tenant_lifecycle_guard(tenant): + self.service.validate_tenant_role_change(actor, user_id, role, tenant=tenant, correlation_id=correlation_id) + identity = next((i for i in self.service.store.identities_for_user(user_id) if i.provider == "netkingdom-lldap"), None) + account = self.service.store.tenant_account(tenant, user_id) + if identity is not None: + if not callable(getattr(self.provisioning, "tenant_access", None)): + raise ValidationError("Tenant-scoped identity changes are unavailable.") + enabled = account is not None and account.status == AccountStatus.ACTIVE + result = self.provisioning.tenant_access(external_subject=identity.subject, tenant=tenant, + roles=(role,), enabled=enabled, idempotency_key=f"portal-role-{tenant}-{user_id}-{role}", correlation_id=correlation_id) + if result.status != ("tenant_active" if enabled else "tenant_disabled"): + raise RuntimeError("tenant role change not confirmed") + self.service.set_tenant_role(actor, user_id, role, tenant=tenant, correlation_id=correlation_id) + return self._redirect(start_response, f"/admin/{tenant}", correlation_id) if len(parts) == 6 and parts[3] == "users" and parts[5] == "status": status = AccountStatus(str(body.get("status", ""))) if status not in {AccountStatus.ACTIVE, AccountStatus.SUSPENDED}: @@ -964,20 +1040,8 @@ class PortalApplication: ) return self._redirect(start_response, f"/admin/{tenant}", correlation_id) if len(parts) == 6 and parts[3] == "users" and parts[5] == "remove": - if self.provisioning is None: - raise ValidationError("identity provisioning is unavailable") - user_id = parts[4] - identity = next(iter(self.service.store.identities_for_user(user_id)), None) - if identity is not None: - self.provisioning.deprovision( - external_subject=identity.subject, - idempotency_key=f"portal-remove-{tenant}-{user_id}", - correlation_id=correlation_id, - ) - self.service.set_tenant_account_status( - actor, user_id, AccountStatus.DISABLED, - tenant=tenant, correlation_id=correlation_id, - ) + self._change_status(actor, tenant, parts[4], AccountStatus.DISABLED, + idempotency_key=f"portal-remove-{tenant}-{parts[4]}", correlation_id=correlation_id) return self._redirect(start_response, f"/admin/{tenant}", correlation_id) if len(parts) == 6 and parts[3] == "users" and parts[5] == "recover": self.service.resolve_tenant_context(actor, PLATFORM_TENANT) @@ -1003,9 +1067,8 @@ class PortalApplication: correlation_id=correlation_id, ) else: - self.provisioning.reconcile( - request, external_subject=identity.subject, desired_status="active" - ) + self._change_status(actor, tenant, user_id, AccountStatus.ACTIVE, + idempotency_key=request.idempotency_key, correlation_id=correlation_id) self.service.set_tenant_account_status( actor, user_id, AccountStatus.ACTIVE, tenant=tenant, correlation_id=correlation_id, @@ -1013,7 +1076,11 @@ class PortalApplication: return self._redirect(start_response, f"/admin/{tenant}?recovered={user_id}", correlation_id) return self._error(start_response, "404 Not Found", "not_found", "Resource not found.", correlation_id) - def _change_status( + def _change_status(self, actor: Any, tenant: str, user_id: str, status: AccountStatus, *, idempotency_key: str, correlation_id: str) -> Any: + with self.service.store.tenant_lifecycle_guard(tenant): + return self._change_status_locked(actor, tenant, user_id, status, idempotency_key=idempotency_key, correlation_id=correlation_id) + + def _change_status_locked( self, actor: Any, tenant: str, @@ -1025,34 +1092,103 @@ class PortalApplication: ) -> Any: if self.provisioning is None: raise ValidationError("identity provisioning is unavailable") - self.service.resolve_tenant_context(actor, tenant) - identity = next( - ( - item for item in self.service.store.identities_for_user(user_id) - if item.provider == "netkingdom-lldap" - ), - None, - ) - if identity is None: - raise ValidationError("user has no managed login identity") - if status == AccountStatus.SUSPENDED: - self.provisioning.suspend( - external_subject=identity.subject, - idempotency_key=idempotency_key, - correlation_id=correlation_id, + self.service.authorize_tenant_member_action(actor, user_id, tenant=tenant, correlation_id=correlation_id) + if status not in {AccountStatus.ACTIVE, AccountStatus.SUSPENDED, AccountStatus.DISABLED}: + raise ValidationError("unsupported tenant account status") + if status != AccountStatus.ACTIVE: + self.service.require_admin_successor(user_id, tenant=tenant) + identity = next((item for item in self.service.store.identities_for_user(user_id) + if item.provider == "netkingdom-lldap"), None) + if identity is not None: + if not callable(getattr(self.provisioning, "tenant_access", None)): + raise ValidationError("Tenant-scoped identity changes are unavailable. No shared login was changed.") + result = self.provisioning.tenant_access( + external_subject=identity.subject, tenant=tenant, + roles=tuple(m.kind for m in self.service.store.memberships_for_user(user_id, tenant=tenant) + if m.scope_type == "tenant" and m.scope_id == tenant), + enabled=status == AccountStatus.ACTIVE, + idempotency_key=idempotency_key, correlation_id=correlation_id, ) - elif status == AccountStatus.ACTIVE: - self.provisioning.reactivate( - external_subject=identity.subject, - idempotency_key=idempotency_key, - correlation_id=correlation_id, - ) - else: - raise ValidationError("provider lifecycle supports active or suspended") + expected = "tenant_active" if status == AccountStatus.ACTIVE else "tenant_disabled" + if result.status != expected: + raise RuntimeError("tenant access change not confirmed") return self.service.set_tenant_account_status( - actor, user_id, status, tenant=tenant, correlation_id=correlation_id + actor, user_id, status, tenant=tenant, correlation_id=correlation_id, ) + def _confirm_change(self, environ: Mapping[str, Any], start_response: StartResponse, + body: Mapping[str, str], snapshot: str, title: str, explanation: str, + correlation_id: str) -> list[bytes] | None: + path = str(environ.get("PATH_INFO", "")) + fields = {k: v for k, v in body.items() if k != "confirm_token"} + state = hashlib.sha256(snapshot.encode()).hexdigest() + def signature(stamp: str) -> str: + material = json.dumps([path, fields, state, stamp], sort_keys=True).encode() + return hmac.new(self.trusted_proxy_secret.encode(), material, hashlib.sha256).hexdigest() + supplied = str(body.get("confirm_token", "")) + if supplied: + stamp, _, digest = supplied.partition(".") + if not stamp.isdigit() or not 0 <= time.time()-int(stamp) <= 600 or not hmac.compare_digest(digest, signature(stamp)): + raise ConflictError("The confirmation expired or the account changed. Refresh and review the action again.") + return None + stamp = str(int(time.time())) + hidden = "".join(f'' for k,v in fields.items()) + details = "".join(f'
  • {escape(k.replace("_", " "))}: {escape(v)}
  • ' for k,v in fields.items() if k in {"status", "operation", "reason", "version", "role"}) + page = self._page_html(title, f'

    {escape(title)}?

    {escape(explanation)}

    ' + f'
    {hidden}' + '

    Cancel without changes

    ') + return self._html(start_response, page, correlation_id) + + def _audit_page(self, tenant: str) -> str: + records = [r for r in self.service.audit_records() if r.tenant == tenant][-100:] + rows = "".join(f'{escape(r.recorded_at.isoformat())}{escape(r.action)}{escape(r.actor.preferred_username or r.actor.subject)}{escape(r.correlation_id)}' for r in reversed(records)) + return self._page_html("Account activity", f'

    Account activity

    Tenant: {escape(tenant)}. Most recent 100 recorded actions. A recorded request is not proof of delivery or effective application access.

    ' + '' + + (rows or '') + '
    TimeActionActorSupport reference
    No recorded activity yet.
    ' + f'

    Return to tenant administration

    ') + + @staticmethod + def _delivery_status(event: Any) -> str: + if event.delivered_at: return "Accepted by delivery adapter; receipt by the person is unverified" + if event.dead_lettered_at: return "Delivery stopped after repeated failures" + if event.failed_at: return "Delivery failed; retry pending" + if event.claimed_by: return "Being processed" + return "Queued for delivery" + + def _operations_page(self, actor: Any, csrf: str, event_id: str, correlation_id: str) -> str: + self.service.tenant_diagnostics(actor, tenant=PLATFORM_TENANT, correlation_id=correlation_id) + events = list(self.service.store.outbox_history())[-100:] + if event_id: + event = self.service.store.outbox_event(event_id) + if event is None: raise NotFoundError("delivery record not found") + events = [event] + rows = "" + for event in events: + action = "" + if event.delivered_at is None and not event.claimed_by and (event.failed_at or event.dead_lettered_at): + action = f'
    ' + rows += f'{escape(event.event_id)}{escape(event.tenant)}{escape(event.event_type)}{escape(self._delivery_status(event))}{escape(event.correlation_id)}{action}' + return self._page_html("Service recovery", '

    Service recovery

    This view shows local delivery records. Live sign-in, email receipt and authenticator health are not verified here.

    ' + '
    ' + '' + + (rows or '') + + '
    DeliveryTenantKindStatusSupport referenceRecovery
    No delivery records. This does not prove mail was received.

    Queued retries are processed by the delivery worker. Check the record again for the result.

    Return to platform administration

    ') + + def _require_setup_access(self, tenant: str, user_id: str) -> None: + account = self.service.store.tenant_account(tenant, user_id) + if account is not None and account.status in {AccountStatus.SUSPENDED, AccountStatus.DISABLED}: + raise ConflictError("Reactivate this tenant account before creating a password setup link.") + + def _require_tenant_admin(self, actor: Any, tenant: str) -> None: + self.service.resolve_tenant_context(actor, tenant) + if not {"tenant-admin", "platform-operator"}.intersection(actor.roles): + raise AuthorizationDenied("tenant administrator role required") + + def _require_invitation_tenant(self, invitation_id: str, tenant: str) -> None: + invitation = self.service.store.family_invitation(invitation_id) + if invitation is None or invitation.tenant != tenant: + raise NotFoundError("invitation not found in this tenant") + def _claims(self, environ: Mapping[str, Any]) -> Mapping[str, Any]: if self.oidc_client is not None: session_id = cookie_value(str(environ.get("HTTP_COOKIE", "")), "ue_session") @@ -1698,18 +1834,28 @@ Use the login name they provide; it may differ from your display name.

    str: rows = "".join( self._admin_row(tenant, item, platform_operator, csrf_token) - for item in memberships + for item in memberships if item.scope_type == "tenant" and item.scope_id == tenant ) or 'No members yet.' invitation_rows = "".join( self._invitation_admin_row(tenant, item, csrf_token) for item in invitations ) or 'No invitations yet.' + progress_items = [] + for journey in self.service.store.onboarding_journeys_for_tenant(tenant): + user = self.service.store.user(journey.user_id) + name = user.display_name or user.user_id if user else "Account" + pending = [step for step in journey.steps if step.status.value not in {"completed", "skipped", "cancelled"}] + steps = "; ".join(step.title + " — " + step.status.value.replace("_", " ") for step in pending) + progress_items.append(f'
  • {escape(name)}: {escape(journey.status.value.replace("_", " "))}. {escape(steps)}' + '

    Ask the person to open My account for profile steps, or use sign-in help for provider steps.

  • ') + progress = "".join(progress_items) or "
  • No additional onboarding journeys are recorded.
  • " diagnostic_items = "".join( f"
  • {escape(item.replace('_', ' '))}
  • " for item in diagnostics.issues ) or "
  • No lifecycle gaps detected.
  • " return self._page_html( f"{tenant} users", f"""

    {escape(tenant)} users

    +

    Account activity and support references · Sign-in recovery help

    Add a user

    @@ -1725,7 +1871,8 @@ Use the login name they provide; it may differ from your display name.

    Role

    Invitations

    {invitation_rows}
    EmailRoleStatusExpiresAction
    -

    Lifecycle diagnostics

    Diagnostics contain machine-readable gap categories only; credentials and factor evidence are never displayed.

    +

    Lifecycle diagnostics

    Check the account and invitation states below. Password and authenticator status are not available here.

    +

    Onboarding follow-up

    These are recorded workflow states. Missing password or authenticator evidence is not proof of completion.

    Members

    {rows}
    UserEmailRoleStatusDirectoryAction
    """, ) @@ -1737,51 +1884,39 @@ Use the login name they provide; it may differ from your display name.

    """ expires = invitation.expires_at.isoformat() if invitation.expires_at else "—" + events = [e for e in self.service.store.outbox_history() if e.tenant == tenant + and e.aggregate_id == invitation.invitation_id and e.event_type in {"family_invitation.created", "family_invitation.resent", "family_member.invited"}] + delivery = self._delivery_status(events[-1]) if events else "Delivery status unavailable" return ( f"{escape(invitation.primary_email)}{escape(invitation.role)}" - f"{escape(invitation.status.value)}{escape(expires)}{actions}" + f"{escape(invitation.status.value)}

    {escape(delivery)}

    {escape(expires)}{actions}" ) - def _admin_row( - self, tenant: str, membership: Any, - platform_operator: bool, csrf_token: str, - ) -> str: + def _admin_row(self, tenant: str, membership: Any, platform_operator: bool, csrf_token: str) -> str: user = self.service.store.user(membership.user_id) - identities = self.service.store.identities_for_user(membership.user_id) - directory = next( - (item for item in identities if item.provider == "netkingdom-lldap"), - None, - ) - tenant_account = self.service.store.tenant_account(tenant, membership.user_id) - status = tenant_account.status if tenant_account else AccountStatus.INVITED - action = ( - f"""Linked as {escape(directory.subject)} -
    - -
    -
    - - -
    """ - if directory - else f"""
    - -
    """ - ) - action += f"""
    - -
    """ - if platform_operator: - action += f"""
    - -
    """ - return ( - f"{escape(user.display_name or membership.user_id) if user else escape(membership.user_id)}" - f"{escape(user.primary_email or '') if user else ''}" - f"{escape(membership.kind)}" - f"{escape(status.value)}" - f"{'linked' if directory else 'pending'}{action}" - ) + directory = next((i for i in self.service.store.identities_for_user(membership.user_id) + if i.provider == "netkingdom-lldap"), None) + account = self.service.store.tenant_account(tenant, membership.user_id) + status = account.status if account else AccountStatus.INVITED + inactive = status in {AccountStatus.SUSPENDED, AccountStatus.DISABLED} + root = f"/admin/{quote(tenant, safe='')}/users/{quote(membership.user_id, safe='')}" + def form(action: str, label: str, **fields: str) -> str: + hidden = "".join(f'' for k,v in fields.items()) + return f'
    {hidden}
    ' + login = (f'

    Login name: {escape(self._directory_login(directory.subject))}

    ' + '

    Password and authenticator status are not available here.

    ') if directory else '

    Login not created. Prepare a login before asking this person to sign in.

    ' + actions = "" + if not inactive: + actions += form("provision", "Create password setup link" if directory else "Create login") + actions += form("status", "Reactivate" if inactive else "Suspend", status="active" if inactive else "suspended") + if status != AccountStatus.DISABLED: + actions += form("remove", "Remove account") + next_role = "user" if membership.kind == "tenant-admin" else "tenant-admin" + actions += form("role", "Make user" if next_role == "user" else "Make tenant administrator", role=next_role) + if platform_operator: actions += form("recover", "Recover identity") + return (f'{escape(user.display_name or membership.user_id) if user else escape(membership.user_id)}' + f'{escape(user.primary_email or "") if user else ""}{escape(membership.kind)}' + f'{escape(status.value)} for this tenant{login}{actions}') def _invitation_acceptance(self, invitation: Any, csrf_token: str) -> str: return self._page_html( @@ -1948,13 +2083,19 @@ Use the login name they provide; it may differ from your display name.

    {escape(step.title)} — {escape(step.status.value)}{gap}{action}" ) - def _password_setup_handoff(self, setup_url: str, tenant: str) -> str: + @staticmethod + def _directory_login(subject: str) -> str: + match = re.fullmatch(r"uid=([A-Za-z0-9._-]+),ou=people,dc=netkingdom,dc=local", subject) + return match.group(1) if match else subject + + def _password_setup_handoff(self, setup_url: str, tenant: str, subject: str = "") -> str: if not setup_url.startswith("https://"): raise ValidationError("password setup handoff must use HTTPS") return self._page_html( "Password setup", - "

    Login identity created

    " - "

    The password is handled only by the NetKingdom identity " + "

    Login ready for password setup

    " + + f"

    Login name: {escape(self._directory_login(subject))}. Use this name when signing in; it may differ from the display name.

    " + + "

    The password is handled only by the NetKingdom identity " "surface. This short-lived link is single use.

    " f'

    ' "Continue to password setup

    " @@ -1972,7 +2113,7 @@ Use the login name they provide; it may differ from your display name.

    Manage users' session_id = cookie_value(str(environ.get("HTTP_COOKIE", "")), "ue_session") @@ -2001,10 +2142,10 @@ a:focus-visible,input:focus-visible,select:focus-visible,button:focus-visible{{o def _html( self, start_response: StartResponse, body: str, correlation_id: str, - *, extra_headers: list[tuple[str, str]] | None = None, + *, extra_headers: list[tuple[str, str]] | None = None, status: str = "200 OK", ) -> list[bytes]: data = body.encode() - start_response("200 OK", [ + start_response(status, [ ("Content-Type", "text/html; charset=utf-8"), ("Content-Length", str(len(data))), *(extra_headers or []), diff --git a/tests/journey-coverage.json b/tests/journey-coverage.json new file mode 100644 index 0000000..6adb02d --- /dev/null +++ b/tests/journey-coverage.json @@ -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": "" + } + ] +} diff --git a/tests/test_journey_postgres.py b/tests/test_journey_postgres.py new file mode 100644 index 0000000..0d41b1a --- /dev/null +++ b/tests/test_journey_postgres.py @@ -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)) diff --git a/tests/test_journey_roles.py b/tests/test_journey_roles.py new file mode 100644 index 0000000..3dd9aa3 --- /dev/null +++ b/tests/test_journey_roles.py @@ -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='