from concurrent.futures import ThreadPoolExecutor from dataclasses import replace 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()==[]