Add lifecycle browser authoring and durable review handoff retrieval
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a10780-6d41-7952-9718-dfbc1f41236f
This commit is contained in:
parent
52d372df0b
commit
9bc581a547
20 changed files with 632 additions and 74 deletions
|
|
@ -12,6 +12,7 @@ import threading
|
|||
import base64
|
||||
import hashlib
|
||||
import ssl
|
||||
from contextlib import ExitStack
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
from wsgiref.simple_server import WSGIRequestHandler, make_server
|
||||
|
|
@ -69,11 +70,20 @@ def main():
|
|||
issuer.params={k:v[0] for k,v in parse_qs(urlsplit(url).query).items()}
|
||||
return url,browser
|
||||
login.start=start
|
||||
with flex_component(os.environ['INFD_FLEX_AUTH_BINARY'],directory,key) as (policy,calls):
|
||||
with flex_component(os.environ['INFD_FLEX_AUTH_BINARY'],directory,key) as (policy,calls), ExitStack() as components:
|
||||
controller.policy=policy
|
||||
pump=AuditPump(controller.store,sink);pump.tick()
|
||||
lifecycle = None
|
||||
if os.environ.get('INFD_FLEX_AUTH_SOURCE'):
|
||||
from informed_decision.lifecycle import LifecycleRepository
|
||||
from informed_decision.lifecycle_service import LifecycleService
|
||||
lifecycle_directory = directory/'lifecycle-policy'; lifecycle_directory.mkdir()
|
||||
lifecycle_policy, _ = components.enter_context(flex_component(
|
||||
os.environ['INFD_FLEX_AUTH_BINARY'], lifecycle_directory, key,
|
||||
fixture=Path(os.environ['INFD_FLEX_AUTH_SOURCE'])/'examples/informed-decision-lifecycle'))
|
||||
lifecycle = LifecycleService(LifecycleRepository(controller.store), lifecycle_policy)
|
||||
pump=AuditPump(controller.store,sink,lifecycle_delivery=lifecycle is not None);pump.tick()
|
||||
assert pump.ready()
|
||||
app=App(login,controller,readiness=pump.ready)
|
||||
app=App(login,controller,readiness=pump.ready,lifecycle=lifecycle)
|
||||
def fixture(environ,start_response):
|
||||
path=environ.get('PATH_INFO')
|
||||
if path=='/authorize':
|
||||
|
|
@ -104,7 +114,8 @@ def main():
|
|||
tls_thread=threading.Thread(target=tls_server.serve_forever,daemon=True);tls_thread.start()
|
||||
with make_server('127.0.0.1',0,fixture,handler_class=Quiet) as server:
|
||||
(directory/'server.json').write_text(json.dumps({'origin':f'http://127.0.0.1:{server.server_port}',
|
||||
'public_origin':public_origin,'issuer':issuer_origin,'certificate_spki':spki,'memo_id':memo.id,'fixture_only':True})+'\n')
|
||||
'public_origin':public_origin,'issuer':issuer_origin,'certificate_spki':spki,'memo_id':memo.id,
|
||||
'lifecycle':lifecycle is not None,'fixture_only':True})+'\n')
|
||||
print('Browser fixture ready',flush=True)
|
||||
try:server.serve_forever()
|
||||
except KeyboardInterrupt:pass
|
||||
|
|
|
|||
|
|
@ -88,6 +88,59 @@ try {
|
|||
assert(state.decision_attributable.every(value=>value===0));
|
||||
checks.push('One actual approval entry, stable on browser reload');
|
||||
checks.push('Audit receiver holds all commitments; unsigned decision gap retained');
|
||||
if (fixture.lifecycle) {
|
||||
await page.goto(fixture.public_origin+'/decisions');
|
||||
await page.getByRole('link',{name:'Prepare a decision',exact:true}).click();
|
||||
await page.getByLabel('Question',{exact:true}).fill('Approve the browser-authored storage proposal?');
|
||||
await page.getByLabel('Context, alternatives and recommendation',{exact:true}).fill('Compare recovery time and costs.');
|
||||
await page.getByLabel('Subject or scope identifier',{exact:true}).fill('storage');
|
||||
await page.getByLabel('Subject or scope label',{exact:true}).fill('Storage strategy');
|
||||
await page.getByLabel('Intended review',{exact:true}).selectOption('approval');
|
||||
await page.getByRole('button',{name:'Save draft',exact:true}).click();
|
||||
const decisionPath=new URL(page.url()).pathname;
|
||||
await page.getByText('Add a supporting document',{exact:true}).click();
|
||||
await page.getByLabel('Document title',{exact:true}).fill('Recovery proposal');
|
||||
await page.getByLabel('Document text',{exact:true}).fill('Recover in one hour. Assess cost and operational risk.');
|
||||
await page.getByLabel('Required acknowledgment (optional)',{exact:true}).fill('The proposal concerns production.');
|
||||
const documentForm=page.locator('form[action$="/document"]');
|
||||
await documentForm.getByLabel('What changed',{exact:true}).fill('Added the review document');
|
||||
await documentForm.getByRole('button',{name:'Save document in new revision',exact:true}).click();
|
||||
await page.getByText('Revision 2 · Storage strategy',{exact:true}).waitFor();
|
||||
checks.push('Browser creates an approval draft and immutable supporting document revision');
|
||||
await page.getByText('Compare with revision 1',{exact:true}).click();
|
||||
await page.getByRole('heading',{name:'Supporting documents changed',exact:true}).waitFor();
|
||||
await page.getByRole('heading',{name:'Review acknowledgments changed',exact:true}).waitFor();
|
||||
await page.getByText('Publish for discussion',{exact:true}).click();
|
||||
await page.getByLabel('Groups',{exact:true}).fill('net-kingdom-admins');
|
||||
await page.getByRole('button',{name:'Publish this revision',exact:true}).click();
|
||||
await page.getByLabel('Responsible party or system',{exact:true}).fill('storage-owner');
|
||||
await page.getByLabel('Purpose of this approval',{exact:true}).fill('Review the recovery proposal');
|
||||
await page.getByRole('button',{name:'Prepare review request',exact:true}).click();
|
||||
const [requestDownload]=await Promise.all([
|
||||
page.waitForEvent('download'),page.getByRole('link',{name:'Download review request',exact:true}).click()
|
||||
]);
|
||||
const envelope=JSON.parse(await fs.readFile(await requestDownload.path(),'utf8'));
|
||||
assert.equal(envelope.memo.step_kind,'approve');
|
||||
assert.equal(envelope.memo.highlights[0].required_ack,true);
|
||||
assert.equal(envelope.packet[0].content,'Recover in one hour. Assess cost and operational risk.');
|
||||
assert.equal(envelope.memo.binding.principal.id,'pending-human-session');
|
||||
assert.equal(envelope.idempotency_key,envelope.memo.id);
|
||||
await page.reload();
|
||||
assert.equal(await page.getByRole('link',{name:'Download review request',exact:true}).count(),1);
|
||||
checks.push('Browser publication and durable handoff download use the actual lifecycle PDP');
|
||||
await page.setViewportSize({width:390,height:844});
|
||||
assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1));
|
||||
await page.screenshot({path:path.join(directory,'lifecycle-mobile.png'),fullPage:true});
|
||||
await page.setViewportSize({width:1360,height:1000});
|
||||
await page.screenshot({path:path.join(directory,'lifecycle-desktop.png'),fullPage:true});
|
||||
state=await (await fetch(fixture.origin+'/_fixture/status')).json();
|
||||
assert.equal(state.entries,1);assert.equal(state.entry_posts,1);assert.equal(state.consume_calls,0);
|
||||
assert(state.outbox_states.every(s=>s==='delivered'));
|
||||
checks.push('Prepared request creates no approval or human entry; lifecycle audit is delivered');
|
||||
await page.goto(fixture.public_origin+'/decisions');
|
||||
assert.equal(await page.locator('a[href="'+decisionPath+'"]').count(),1);
|
||||
await page.goto(fixture.public_origin+'/review?memo_id='+encodeURIComponent(fixture.memo_id));
|
||||
}
|
||||
await fetch(fixture.origin+'/_fixture/refuse-caller');
|
||||
await page.reload();await page.getByRole('heading',{name:'Review unavailable'}).waitFor();
|
||||
assert(!(await page.content()).includes('private-brief-sentinel'));
|
||||
|
|
|
|||
|
|
@ -355,3 +355,24 @@ def test_intake_policy_guard_rechecks_retained_metadata(handoff):
|
|||
with pytest.raises(Conflict, match='policy observation'):
|
||||
store.present(review.id, principal_sub=session.subject, tenant=session.tenant,
|
||||
principal_type=session.principal_type, policy_id=policy_id)
|
||||
|
||||
|
||||
def test_retained_handoff_api_is_revision_scoped_and_reauthorizes(handoff):
|
||||
from informed_decision.lifecycle_http import LifecycleAPI
|
||||
from test_lifecycle_http import call
|
||||
app, actor, memo, options, intake, calls = handoff
|
||||
api = LifecycleAPI(app, lambda _: actor)
|
||||
path = '/v1/decisions/' + memo.id + '/review-requests'
|
||||
assert call(api, path, QUERY_STRING='version=1')[0] == 404
|
||||
receipt = prepare(handoff)
|
||||
assert call(api, path, QUERY_STRING='version=1')[:2] == (200, receipt)
|
||||
assert not calls
|
||||
for query in ('', 'version=0', 'version=1&extra=x', 'version=1&version=1'):
|
||||
assert call(api, path, QUERY_STRING=query)[0] == 400
|
||||
app.revise(actor, memo.next_version(brief='Next revision'), expected_version=1, summary='Refined', key='revise')
|
||||
assert call(api, path, QUERY_STRING='version=2')[0] == 404
|
||||
# Historical export is evidence, never permission to resubmit a stale request.
|
||||
assert call(api, path, QUERY_STRING='version=1')[:2] == (200, receipt)
|
||||
assert request(intake, receipt['envelope'])[0] == 409
|
||||
app.policy.transport.change = lambda data: data.update(effect='deny')
|
||||
assert call(api, path, query='version=1')[:2] == (403, {'error': 'policy_denied'})
|
||||
|
|
|
|||
|
|
@ -90,3 +90,93 @@ def test_workspace_escapes_content_and_policy_failure_returns_no_memo(browser):
|
|||
lifecycle.policy.transport.change = lambda data: data.update(effect="deny")
|
||||
response = call(app, path, cookie=cookie)
|
||||
assert response["status"] == 403 and "Compare cost" not in response["body"]
|
||||
|
||||
|
||||
def approval_draft(browser):
|
||||
response = post(browser, '/decisions/new/prepare', question='Approve the storage proposal?',
|
||||
brief='Compare recovery time and costs.', scope_id='storage', scope_label='Storage strategy',
|
||||
review_profile='approval')
|
||||
assert response['status'] == 303
|
||||
return dict(response['headers'])['Location']
|
||||
|
||||
|
||||
def test_browser_authors_packet_and_exports_exact_durable_review_handoff(browser):
|
||||
import json
|
||||
from informed_decision.lifecycle import LifecycleRepository
|
||||
from informed_decision.lifecycle_service import LifecycleService
|
||||
from informed_decision.store import Store
|
||||
app, lifecycle, session, cookie = browser
|
||||
path = approval_draft(browser)
|
||||
fields = dict(version='1', label='Storage proposal', content='Recover in one hour. Cost: 50 EUR.',
|
||||
highlight='This is a production change.', summary='Added the proposal', key=str(uuid.uuid4()))
|
||||
assert post(browser, path+'/document', **fields)['status'] == 303
|
||||
assert post(browser, path+'/document', **fields)['status'] == 303
|
||||
memo_id = path.split('/')[-1]
|
||||
assert lifecycle.repository.store.memo(memo_id).version == 2
|
||||
assert lifecycle.repository.store.memo(memo_id, 1).packet == ()
|
||||
packet = call(app, path+'/packet/0', cookie=cookie, query='version=2')
|
||||
assert packet['status'] == 200 and packet['body'] == fields['content']
|
||||
comparison = call(app, path, cookie=cookie)['body']
|
||||
assert 'Supporting documents changed' in comparison and 'Review acknowledgments changed' in comparison
|
||||
assert fields['highlight'] in comparison
|
||||
assert post(browser, path+'/publish', version='2', subjects='', groups='net-kingdom-admins')['status'] == 303
|
||||
review = dict(version='2', principal='storage-owner', purpose='Approve the recovery proposal', key=str(uuid.uuid4()))
|
||||
assert post(browser, path+'/request-review', **review)['status'] == 303
|
||||
assert post(browser, path+'/request-review', **review)['status'] == 303
|
||||
page = call(app, path, cookie=cookie)
|
||||
assert 'Download review request' in page['body'] and 'has not created an approval' in page['body']
|
||||
exported = call(app, path+'/review-request', cookie=cookie, query='version=2')
|
||||
assert exported['status'] == 200 and 'attachment;' in dict(exported['headers'])['Content-Disposition']
|
||||
envelope = json.loads(exported['body'])
|
||||
assert envelope['packet'][0]['content'] == fields['content']
|
||||
assert envelope['memo']['highlights'][0]['required_ack'] is True
|
||||
assert envelope['memo']['binding']['principal']['id'] == 'pending-human-session'
|
||||
assert envelope['approval_intent']['principal'] == 'storage-owner'
|
||||
assert envelope['memo_class'] == 'decision-record' and envelope['review_group'] == 'net-kingdom-admins'
|
||||
reopened = LifecycleService(LifecycleRepository(Store(lifecycle.repository.store.path)), lifecycle.policy)
|
||||
retained = reopened.review_handoff(Caller.from_human_session(session), memo_id, 2)
|
||||
assert retained['envelope'] == envelope
|
||||
with reopened.repository.store._connection() as db:
|
||||
assert db.execute('select count(*) from intake_ledger').fetchone()[0] == 0
|
||||
assert db.execute('select count(*) from submissions').fetchone()[0] == 0
|
||||
assert post(browser, path+'/revise', version='2', question='Approve the revised storage proposal?',
|
||||
brief='A different proposal.', summary='Changed the proposal')['status'] == 303
|
||||
current = call(app, path, cookie=cookie)['body']
|
||||
assert 'Download review request' not in current
|
||||
assert 'earlier revision' in call(app, path, cookie=cookie, query='version=2')['body']
|
||||
|
||||
|
||||
def test_packet_conflict_and_rejected_content_leave_no_partial_revision(browser):
|
||||
app, lifecycle, session, cookie = browser
|
||||
path = approval_draft(browser)
|
||||
fields = dict(version='1', label='Document', content='Supporting text', highlight='', summary='Add document')
|
||||
assert post(browser, path+'/document', **fields)['status'] == 303
|
||||
assert post(browser, path+'/document', **{**fields, 'content':'Concurrent replacement'})['status'] == 409
|
||||
assert post(browser, path+'/document', **{**fields, 'version':'2', 'content':'-----BEGIN OPENSSH PRIVATE KEY-----'})['status'] == 400
|
||||
assert lifecycle.repository.store.memo(path.split('/')[-1]).version == 2
|
||||
assert len(lifecycle.repository.store.memo(path.split('/')[-1]).packet) == 1
|
||||
assert call(app, path+'/review-request', cookie=cookie, query='version=2')['status'] == 404
|
||||
|
||||
|
||||
def test_review_and_document_mutations_require_policy_origin_and_csrf(browser):
|
||||
app, lifecycle, session, cookie = browser
|
||||
path = approval_draft(browser)
|
||||
fields = dict(version='1', label='Document', content='Supporting text', highlight='', summary='Add document')
|
||||
assert post(browser, path+'/document', **fields, csrf='wrong')['status'] == 403
|
||||
assert post(browser, path+'/request-review', version='1', principal='owner', purpose='Review', csrf='wrong')['status'] == 403
|
||||
lifecycle.policy.transport.change = lambda data: data.update(effect='deny')
|
||||
assert post(browser, path+'/document', **fields)['status'] == 403
|
||||
assert call(app, path+'/review-request', cookie=cookie, query='version=1')['status'] == 403
|
||||
assert lifecycle.repository.store.memo(path.split('/')[-1]).version == 1
|
||||
|
||||
|
||||
def test_review_profiles_are_explicit_and_no_browser_route_binds(browser):
|
||||
app, lifecycle, session, cookie = browser
|
||||
form = call(app, '/decisions/new/prepare', cookie=cookie)['body']
|
||||
assert 'Human approval (organizational)' in form and 'Discussion only' in form
|
||||
response = post(browser, '/decisions/new/prepare', question='Discuss the storage proposal?', brief='Discuss alternatives.',
|
||||
scope_id='storage', scope_label='Storage', review_profile='discussion')
|
||||
path = dict(response['headers'])['Location']
|
||||
assert 'not supported by the current human approval flow' in call(app, path, cookie=cookie)['body']
|
||||
assert post(browser, path+'/request-review', version='1', principal='owner', purpose='Review')['status'] == 400
|
||||
assert post(browser, path+'/accept', version='1')['status'] == 404
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue