from concurrent.futures import ThreadPoolExecutor from dataclasses import replace from datetime import datetime, timedelta, timezone import json import sqlite3 import time from urllib.parse import urlencode import uuid import pytest from informed_decision.approval_http import ApprovalHTTPClient from informed_decision.disposition import DispositionRefused, Verb from informed_decision.http_transport import TransportError from informed_decision.memo import BindingLevel, Highlight, PacketItem, Principal, Scope, StepKind from informed_decision.policy import PolicyError from informed_decision.review import ReviewController, ReviewError from informed_decision.store import Conflict, Store from informed_decision.web import App, SESSION_COOKIE from informed_decision.oidc import ORIGIN from test_approval_component import component, signing_key from test_browser_auth import call from test_policy_client import policy from test_skeleton import make_memo @pytest.fixture def review(component, tmp_path): client,engine,transport,session,_=component private=tmp_path/'private';private.mkdir(mode=0o700) store=Store(private/'review.sqlite') packet=store.put_document(b'\nFull fixture terms.') memo=make_memo(approval_id='fixture',approval_binding_digest=client.get_approval('fixture')['binding']['digest'], question='Approve the synthetic factory delivery?', requested_act='deliver', highlights=(Highlight('h-1','doc-1','Disposable test target; no production action',required_ack=True),), packet=(PacketItem('doc-1','Whole fixture document',packet),), brief='Disposable browser/component exercise. private-brief-sentinel ') memo=replace(memo,binding=replace(memo.binding,principal=Principal(session.subject,'person','Fixture reviewer'), target=Scope('resource','fixture','Synthetic factory target','test'))) store.save_memo(memo) controller=ReviewController(store,policy(),lambda s:ApprovalHTTPClient('https://approval.test',s,transport=transport)) class Login: def session(self,sid):return session if sid=='fixture-session' else None app=App(Login(),controller,readiness=lambda:True) return controller,session,app,memo,engine,transport def opened(review): controller,session,*_=review return controller.open(session,'memo-1') def post(review,p,route,**params): _,session,app,*_=review return call(app,'/presentations/'+p.id+'/'+route,'POST',cookie=SESSION_COOKIE+'=fixture-session', origin=ORIGIN,body=urlencode({'csrf':session.csrf,**params}).encode()) def test_read_denial_precedes_disclosure_engine_read_and_presentation(review): controller,session,app,memo,engine,transport=review controller.policy.transport.change=lambda d:d.update(effect='deny') before=len(transport.calls) r=call(app,'/review',query='memo_id=memo-1',cookie=SESSION_COOKIE+'=fixture-session') assert r['status']==403 and 'private-brief-sentinel' not in r['body'] assert len(transport.calls)==before and controller.store.evidence()==[] assert controller.store.policy_observations()[0]['outcome']=='policy_denied' def test_no_session_or_wrong_named_recipient_cannot_check_or_render(review): controller,session,app,*_=review assert call(app,'/review',query='memo_id=memo-1')['status']==401 with pytest.raises(ReviewError):controller.open(replace(session,subject='other'),'memo-1') assert controller.policy.transport.calls==[] and controller.store.policy_observations()==[] @pytest.mark.parametrize('change', [{'locale':'de'}, {'binding_level':BindingLevel.AES}, {'binding_level':BindingLevel.QES}, {'step_kind':StepKind.REVIEW}]) def test_unsupported_profile_cannot_misstate_locale_or_binding_level(review,change): controller,session,_,memo,*_=review controller.store.save_memo(memo.next_version(**change)) with pytest.raises(ReviewError,match='unsupported_review_profile'): controller.open(session,memo.id) assert controller.policy.transport.calls==[] and controller.store.evidence()==[] def test_open_persists_exact_policy_and_binding_but_not_browser_credential(review): controller,session,app,*_=review page=opened(review);content=controller.store.presentation_content(page.presentation.id) observed=controller.store.policy_observations() assert observed[0]['decision_attributable']==0 assert content['policy_observation_id']==observed[0]['id'] assert content['approval_binding']['digest']==page.memo.approval_binding_digest assert session.access_token not in json.dumps(observed) assert all('policy_observation_id' not in r['envelope'] for r in controller.store.evidence()) response=call(app,'/presentations/'+page.presentation.id,cookie=SESSION_COOKIE+'=fixture-session') assert response['status']==200 and '<img' in response['body'] and '' not in response['body'] assert not any(url.endswith('/consume') for _,url in transport.calls) @pytest.mark.parametrize('verb',[Verb.RETURN,Verb.DISCUSS,Verb.DECLINE]) def test_nonaccept_actions_preserve_vocabulary_without_engine_mutation(review,verb): controller,session,app,memo,engine,transport=review;p=opened(review).presentation controller.acknowledge(session,p.id,['h-1']) result=post(review,p,'act',verb=verb.value,operation_id=str(uuid.uuid4()),reason='clarification_needed' if verb is Verb.RETURN else '',note='Private response') assert result['status']==303 and not engine.get('fixture').entries assert controller.store.dispositions_for(p.id)[0].verb is verb response=call(app,'/presentations/'+p.id,cookie=SESSION_COOKIE+'=fixture-session') assert 'Private response' in response['body'] assert all('Private response' not in r['envelope'] for r in controller.store.evidence()) def test_fresh_act_refusal_after_allowed_render_cannot_submit(review): controller,session,app,memo,engine,transport=review;p=opened(review).presentation controller.acknowledge(session,p.id,['h-1']) controller.policy.transport.change=lambda d:d.update(effect='deny') assert post(review,p,'act',verb='accept',operation_id=str(uuid.uuid4()))['status']==403 assert not engine.get('fixture').entries and controller.store.dispositions_for(p.id)==[] def test_policy_storage_failure_prevents_presentation(review): controller,session,*_=review with sqlite3.connect(controller.store.path) as db: db.execute("CREATE TRIGGER fail_observation BEFORE INSERT ON policy_observations BEGIN SELECT RAISE(ABORT,'disk failure'); END") with pytest.raises(sqlite3.IntegrityError):opened(review) assert controller.store.evidence()==[] def test_revision_between_policy_and_present_cannot_render_unchecked_version(review): controller,session,_,memo,*_=review controller.policy.transport.change=lambda d:controller.store.save_memo(memo.next_version(brief='changed')) with pytest.raises(Conflict):opened(review) assert controller.store.evidence()==[] def test_stale_presentation_and_wrong_actor_cannot_ack_or_act(review): controller,session,_,memo,engine,_=review;p=opened(review).presentation with pytest.raises(ReviewError):controller.acknowledge(replace(session,subject='other'),p.id,['h-1']) controller.store.save_memo(memo.next_version(brief='new version')) with pytest.raises(ReviewError):controller.acknowledge(session,p.id,['h-1']) with pytest.raises(ReviewError):controller.act(session,p.id,Verb.ACCEPT,operation_id='stale') assert not engine.get('fixture').entries def test_expiry_after_last_engine_read_prevents_reservation_and_post(review): controller,session,_,memo,engine,transport=review;p=opened(review).presentation controller.acknowledge(session,p.id,['h-1']) original=transport.request;reads=0 def request(method,url,**kwargs): nonlocal reads result=original(method,url,**kwargs) if method=='GET': reads+=1 if reads==2:controller.clock=lambda:time.time()+40 return result transport.request=request with pytest.raises(PolicyError):controller.act(session,p.id,Verb.ACCEPT,operation_id='late') assert not engine.get('fixture').entries assert controller.store.intent_for('fixture',session.subject)['state']=='prepared' def test_parallel_clicks_submit_once(review): controller,session,_,memo,engine,transport=review;p=opened(review).presentation controller.acknowledge(session,p.id,['h-1']) def click(_):return controller.act(session,p.id,Verb.ACCEPT,operation_id='one-click') with ThreadPoolExecutor(max_workers=2) as pool:assert list(pool.map(click,range(2)))==[p.id,p.id] assert len(engine.get('fixture').entries)==1 assert sum(method=='POST' for method,_ in transport.calls)==1 def test_lost_entry_reply_is_visible_and_never_retried(review): controller,session,app,memo,engine,transport=review;p=opened(review).presentation controller.acknowledge(session,p.id,['h-1']) original=transport.request def lose_reply(method,url,**kwargs): result=original(method,url,**kwargs) if method=='POST':raise TransportError('lost response') return result transport.request=lose_reply controller.act(session,p.id,Verb.ACCEPT,operation_id='lost') assert controller.store.intent_for('fixture',session.subject)['state']=='unresolved' response=call(app,'/presentations/'+p.id,cookie=SESSION_COOKIE+'=fixture-session') assert 'Submission outcome is not confirmed.' in response['body'] assert 'Accept the complete request' not in response['body'] controller.act(session,p.id,Verb.ACCEPT,operation_id='new-click') assert len(engine.get('fixture').entries)==1 and sum(m=='POST' for m,_ in transport.calls)==1 def test_unknown_existing_entry_never_acquires_a_new_presentation_link(review): controller,session,_,memo,engine,transport=review;p=opened(review).presentation ApprovalHTTPClient('https://approval.test',session,transport=transport).add_entry('fixture') with pytest.raises(ReviewError,match='existing_entry_unlinked'): controller.act(session,p.id,Verb.ACCEPT,operation_id='unlinked') assert controller.store.intent_for('fixture',session.subject) is None assert sum(m=='POST' for m,_ in transport.calls)==1 def test_packet_download_rechecks_entitlement_and_forces_attachment(review): controller,session,app,*_=review;p=opened(review).presentation path='/presentations/'+p.id+'/packet/0' response=call(app,path,cookie=SESSION_COOKIE+'=fixture-session') assert response['status']==200 assert dict(response['headers'])['Content-Type']=='application/octet-stream' assert dict(response['headers'])['Content-Disposition'].startswith('attachment;') controller.policy.transport.change=lambda d:d.update(effect='deny') denied=call(app,path,cookie=SESSION_COOKIE+'=fixture-session') assert denied['status']==403 and 'private-packet-sentinel' not in denied['body'] def test_audit_not_ready_blocks_accept_before_policy_or_post(review): controller,session,app,memo,engine,_=review;p=opened(review).presentation app.readiness=lambda:False assert post(review,p,'act',verb='accept',operation_id=str(uuid.uuid4()))['status']==503 assert not engine.get('fixture').entries assert call(app,'/readyz')['status']==503 def test_renderer_release_cannot_silently_change_under_a_memo(review): controller,session,_,memo,*_=review controller.store.save_memo(memo.next_version(ui_release='informed-decision@0.1.0')) with pytest.raises(ReviewError,match='renderer_changed'):opened(review) assert controller.store.evidence()==[] @pytest.mark.parametrize("padding", ["", " ", "\t\r\n", "\u00a0"]) def test_pasted_memo_id_opens_current_version(review, padding): controller, session, app, memo, engine, _ = review controller.store.save_memo(memo.next_version()) response = call(app, '/review', cookie=SESSION_COOKIE+'=fixture-session', query=urlencode({'memo_id': padding+memo.id+padding})) assert response['status'] == 200 observations = controller.store.policy_observations() assert json.loads(observations[-1]['request'])['context']['memo_version'] == 2 assert not engine.get('fixture').entries @pytest.mark.parametrize("query", ["memo_id=", "memo_id=+", "memo_id=%60memo-1%60", "memo_id=memo-1&memo_id=memo-1", "memo_id=memo-1&extra=x", "memo_id=memo+1", "memo_id=https%3A%2F%2Fdecisions.coulomb.social%2Freview", "memo_id=%FF"]) def test_invalid_memo_input_explains_correction_before_policy(review, query): controller, _, app, _, engine, _ = review response = call(app, '/review', cookie=SESSION_COOKIE+'=fixture-session', query=query) assert response['status'] == 400 assert 'Enter the memo identifier only' in response['body'] assert not controller.store.policy_observations() assert not engine.get('fixture').entries def add_memo(review, approval_id): controller,session,app,memo,engine,transport=review now=datetime.now(timezone.utc) engine.create({"actor":"synthetic-requester","principal":"factory-fixture","action":"deliver", "purpose":"local-component-proof","target":{"resource":approval_id}}, {"not_before":(now-timedelta(seconds=1)).isoformat(),"expires_at":(now+timedelta(minutes=10)).isoformat()}, human_control=True,approval_id=approval_id) digest=controller.approval_factory(session).get_approval(approval_id)['binding']['digest'] added=replace(memo,id='memo-'+approval_id,approval_id=approval_id,approval_binding_digest=digest, question='Question for '+approval_id) controller.store.save_memo(added) return added def groups(controller,session): return {r.memo_id:r.group for r in controller.overview(session)} def test_overview_classifies_without_presenting_or_binding(review): controller,session,app,memo,engine,transport=review for name in ('declined','returned','closed'):add_memo(review,name) other=replace(memo,id='memo-other',binding=replace(memo.binding,principal=Principal('someone-else','person','Other'))) controller.store.save_memo(other) before=len(controller.store.evidence()) rows=controller.overview(session) assert len(controller.store.evidence())==before and not any(m=='POST' for m,_ in transport.calls) assert {r.memo_id:(r.group,r.engine_status) for r in rows}=={ 'memo-1':('open','requested'),'memo-declined':('open','requested'), 'memo-returned':('open','requested'),'memo-closed':('open','requested')} assert rows[0].memo.question and rows[0].history==() p=opened(review).presentation;controller.acknowledge(session,p.id,['h-1']) controller.act(session,p.id,Verb.ACCEPT,operation_id=str(uuid.uuid4())) for name,verb in (('declined',Verb.DECLINE),('returned',Verb.RETURN)): q=controller.open(session,'memo-'+name).presentation;controller.acknowledge(session,q.id,['h-1']) controller.act(session,q.id,verb,operation_id=str(uuid.uuid4()), reasons=('clarification_needed',) if verb is Verb.RETURN else ()) engine.revoke('closed') rows={r.memo_id:r for r in controller.overview(session)} assert {k:r.group for k,r in rows.items()}=={'memo-1':'accepted','memo-declined':'declined', 'memo-returned':'returned','memo-closed':'closed'} assert rows['memo-1'].intent['state']=='confirmed' and rows['memo-1'].intent['approved_at'] assert rows['memo-1'].engine_status=='approved' and rows['memo-closed'].engine_status=='revoked' assert [d.verb for d,_ in rows['memo-declined'].history]==[Verb.DECLINE] # A revision after a return reopens the question on the new version. controller.store.save_memo(replace(controller.store.memo('memo-returned'),version=2)) assert groups(controller,session)['memo-returned']=='open' def test_overview_redacts_rows_the_pdp_does_not_allow(review): controller,session,app,memo,engine,transport=review controller.policy.transport.change=lambda d:d.update(effect='deny') before=len(transport.calls) [row]=controller.overview(session) assert (row.memo_id,row.group,row.reason,row.memo,row.history)==('memo-1','unavailable','policy_denied',None,()) assert len(transport.calls)==before def test_overview_degrades_one_row_when_engine_is_unreachable(review): controller,session,app,memo,engine,transport=review original=transport.request def down(method,url,**kwargs): raise TransportError('down') transport.request=down [row]=controller.overview(session) assert (row.group,row.engine_status,row.reason)==('open',None,'engine_unavailable') assert row.memo.question==memo.question transport.request=original def test_overview_surfaces_unconfirmed_submission(review): controller,session,app,memo,engine,transport=review;p=opened(review).presentation controller.acknowledge(session,p.id,['h-1']) original=transport.request def lose_reply(method,url,**kwargs): result=original(method,url,**kwargs) if method=='POST':raise TransportError('lost response') return result transport.request=lose_reply controller.act(session,p.id,Verb.ACCEPT,operation_id='lost') assert groups(controller,session)=={'memo-1':'attention'} def test_overview_requires_a_live_human_session(review): controller,session,*_=review with pytest.raises(ReviewError) as error: controller.overview(replace(session,expires_at=0)) assert error.value.code=='session_expired' def home(review): return call(review[2],'/',cookie=SESSION_COOKIE+'=fixture-session') def test_home_lists_open_then_accepted_memos_and_keeps_the_manual_form(review): controller,session,app,memo,engine,transport=review r=home(review) assert r['status']==200 and 'Open for you (1)' in r['body'] and 'Accepted (' not in r['body'] assert 'Approve the synthetic factory delivery?' in r['body'] assert 'Approval status: requested' in r['body'] and 'Open a decision review' in r['body'] assert 'private-brief-sentinel' not in r['body'] and 'private-packet-sentinel' not in r['body'] assert len(controller.store.evidence())==0 p=opened(review).presentation;controller.acknowledge(session,p.id,['h-1']) controller.act(session,p.id,Verb.ACCEPT,operation_id=str(uuid.uuid4())) r=home(review) assert 'Accepted (1)' in r['body'] and 'Open for you' not in r['body'] assert 'Approval status: approved' in r['body'] and 'Entry recorded ' in r['body'] assert '
  • Accept · version 1 · ' in r['body'] and 'confirmed' in r['body'] def test_home_escapes_memo_text_and_redacts_refused_rows(review): controller,session,app,memo,engine,transport=review hostile=add_memo(review,'hostile') controller.store.save_memo(replace(hostile,version=2,question='')) r=home(review) assert '