Connect policy-gated browser review and audit runtime
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc
This commit is contained in:
parent
2cc32168ac
commit
83849b75d4
35 changed files with 2381 additions and 83 deletions
116
tests/browser_fixture.py
Normal file
116
tests/browser_fixture.py
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
"""Local-only browser exercise over actual components and synthetic identity.
|
||||
|
||||
Run with all three INFD_* component source/binary variables, and pass an output
|
||||
directory. The fixture controls below are NOT part of informed_decision.web.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import threading
|
||||
import base64
|
||||
import hashlib
|
||||
import ssl
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
from wsgiref.simple_server import WSGIRequestHandler, make_server
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.x509.oid import NameOID
|
||||
|
||||
from informed_decision.oidc import KeyCapeLogin
|
||||
from informed_decision.runtime import AuditPump
|
||||
from informed_decision.web import App
|
||||
from test_browser_auth import ISSUER, IssuerFixture, signing_key
|
||||
from test_approval_component import component
|
||||
from test_durable_component import receiver
|
||||
from test_review_controller import review
|
||||
from review_components import flex_component
|
||||
|
||||
|
||||
def main():
|
||||
directory=Path(sys.argv[1]);directory.mkdir(mode=0o700,parents=True,exist_ok=True)
|
||||
key=signing_key.__wrapped__()
|
||||
class Quiet(WSGIRequestHandler):
|
||||
def log_message(self,*args):pass
|
||||
tls_server=make_server('127.0.0.1',0,lambda e,s:[],handler_class=Quiet)
|
||||
public_origin=f'https://decisions.coulomb.social:{tls_server.server_port}'
|
||||
issuer_origin=f'https://keycape.test:{tls_server.server_port}'
|
||||
# Test-only ports, real HTTPS and redirects. Exact production strings stay
|
||||
# pinned by the separate registration/login contract tests.
|
||||
import informed_decision.oidc as oidc_module
|
||||
import informed_decision.web as web_module
|
||||
import test_browser_auth as browser_auth
|
||||
import test_approval_component as approval_component
|
||||
oidc_module.ORIGIN=web_module.ORIGIN=public_origin
|
||||
oidc_module.CALLBACK=browser_auth.CALLBACK=public_origin+'/auth/callback'
|
||||
browser_auth.ISSUER=approval_component.ISSUER=issuer_origin
|
||||
now=datetime.now(timezone.utc)
|
||||
name=x509.Name([x509.NameAttribute(NameOID.COMMON_NAME,'Browser fixture')])
|
||||
certificate=(x509.CertificateBuilder().subject_name(name).issuer_name(name).public_key(key.public_key())
|
||||
.serial_number(x509.random_serial_number()).not_valid_before(now-timedelta(minutes=1))
|
||||
.not_valid_after(now+timedelta(hours=1)).add_extension(x509.SubjectAlternativeName([
|
||||
x509.DNSName('decisions.coulomb.social'),x509.DNSName('keycape.test')]),critical=False).sign(key,hashes.SHA256()))
|
||||
cert_path=directory/'browser-cert.pem';cert_path.write_bytes(certificate.public_bytes(serialization.Encoding.PEM))
|
||||
key_path=directory/'browser-key.pem';key_path.write_bytes(key.private_bytes(serialization.Encoding.PEM,
|
||||
serialization.PrivateFormat.PKCS8,serialization.NoEncryption()));key_path.chmod(0o600)
|
||||
context=ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER);context.load_cert_chain(cert_path,key_path)
|
||||
tls_server.socket=context.wrap_socket(tls_server.socket,server_side=True)
|
||||
spki=base64.b64encode(hashlib.sha256(key.public_key().public_bytes(serialization.Encoding.DER,
|
||||
serialization.PublicFormat.SubjectPublicKeyInfo)).digest()).decode()
|
||||
engine_fixture=component.__wrapped__(key,directory);engine_parts=next(engine_fixture)
|
||||
sink,receiver_transport,backend=receiver.__wrapped__(directory)
|
||||
controller,_,_,memo,engine,transport=review.__wrapped__(engine_parts,directory)
|
||||
issuer=IssuerFixture(key);login=KeyCapeLogin(issuer_origin,transport=issuer)
|
||||
original_start=login.start
|
||||
def start():
|
||||
url,browser=original_start()
|
||||
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):
|
||||
controller.policy=policy
|
||||
pump=AuditPump(controller.store,sink);pump.tick()
|
||||
assert pump.ready()
|
||||
app=App(login,controller,readiness=pump.ready)
|
||||
def fixture(environ,start_response):
|
||||
path=environ.get('PATH_INFO')
|
||||
if path=='/authorize':
|
||||
params={k:v[0] for k,v in parse_qs(environ.get('QUERY_STRING','')).items()}
|
||||
assert params['code_challenge_method']=='S256'
|
||||
assert params['redirect_uri']==oidc_module.CALLBACK
|
||||
start_response('303 See Other',[('Location',oidc_module.CALLBACK+'?state='+params['state']+'&code=synthetic-browser-code')])
|
||||
return [b'']
|
||||
if path=='/_fixture/status':
|
||||
pump.tick()
|
||||
intents=[controller.store.intent_for('fixture','human-fixture')]
|
||||
payload={'entries':len(engine.get('fixture').entries),'intents':intents,
|
||||
'outbox_states':[r['state'] for r in controller.store.outbox()],
|
||||
'policy_outcomes':[r['outcome'] for r in controller.store.policy_observations()],
|
||||
'decision_attributable':[r['decision_attributable'] for r in controller.store.policy_observations()],
|
||||
'entry_posts':sum(m=='POST' for m,_ in transport.calls),
|
||||
'consume_calls':sum(url.endswith('/consume') for _,url in transport.calls),
|
||||
'tokenreviews':len(calls)}
|
||||
raw=json.dumps(payload).encode();start_response('200 OK',[('Content-Type','application/json')]);return [raw]
|
||||
if path=='/_fixture/refuse-caller':
|
||||
policy.token_provider=lambda:'synthetic-wrong-caller'
|
||||
start_response('200 OK',[('Content-Type','application/json')]);return [b'{}']
|
||||
if path=='/_fixture/stop':
|
||||
threading.Thread(target=server.shutdown,daemon=True).start()
|
||||
start_response('200 OK',[('Content-Type','application/json')]);return [b'{}']
|
||||
return app(environ,start_response)
|
||||
tls_server.set_app(fixture)
|
||||
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')
|
||||
print('Browser fixture ready',flush=True)
|
||||
try:server.serve_forever()
|
||||
except KeyboardInterrupt:pass
|
||||
finally:
|
||||
tls_server.shutdown();tls_server.server_close();tls_thread.join(timeout=5)
|
||||
engine_fixture.close()
|
||||
|
||||
|
||||
if __name__=='__main__':main()
|
||||
117
tests/browser_review.mjs
Normal file
117
tests/browser_review.mjs
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
// Automated browser contract exercise. All identities and grants are synthetic.
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import {createRequire} from 'node:module';
|
||||
const require=createRequire(import.meta.url);
|
||||
const {chromium}=require(process.env.INFD_PLAYWRIGHT_PACKAGE || 'playwright');
|
||||
const directory=process.argv[2];
|
||||
const fixture=JSON.parse(await fs.readFile(path.join(directory,'server.json'),'utf8'));
|
||||
const checks=[];
|
||||
const browser=await chromium.launch({headless:true,executablePath:process.env.INFD_CHROMIUM_EXECUTABLE,args:[
|
||||
'--no-proxy-server',
|
||||
'--host-resolver-rules=MAP decisions.coulomb.social 127.0.0.1, MAP keycape.test 127.0.0.1',
|
||||
'--ignore-certificate-errors-spki-list='+fixture.certificate_spki
|
||||
]});
|
||||
const context=await browser.newContext({viewport:{width:1360,height:1000},acceptDownloads:true});
|
||||
const errors=[];
|
||||
const navigation=[];
|
||||
const formOrigins=[];
|
||||
try {
|
||||
// Both test names resolve to the disposable HTTPS server. All redirects,
|
||||
// browser cookies and form Origins travel through real HTTP/TLS. Only this
|
||||
// fixture certificate's public key is trusted by the browser override.
|
||||
const page=await context.newPage();page.setDefaultTimeout(10000);
|
||||
page.on('pageerror',error=>errors.push(String(error)));
|
||||
page.on('request',request=>{if(request.method()==='POST')formOrigins.push({
|
||||
path:new URL(request.url()).pathname,origin:request.headers().origin || null});});
|
||||
page.on('response',response=>navigation.push({path:new URL(response.url()).pathname,status:response.status()}));
|
||||
page.on('dialog',async dialog=>{errors.push('Unexpected script dialog');await dialog.dismiss();});
|
||||
await page.goto(fixture.public_origin+'/');
|
||||
await page.getByRole('link',{name:'Sign in with KeyCape'}).click();
|
||||
await page.getByRole('heading',{name:'Open a decision review'}).waitFor({timeout:10000});
|
||||
const cookies=await context.cookies();
|
||||
const session=cookies.find(c=>c.name==='__Host-infd-session');
|
||||
assert(session?.secure && session?.httpOnly && session?.sameSite==='Lax');
|
||||
checks.push('PKCE callback and secure browser session');
|
||||
await page.getByLabel('Memo identifier').fill(fixture.memo_id);
|
||||
await page.getByRole('button',{name:'Open review',exact:true}).click();
|
||||
await page.getByRole('heading',{name:'The request',exact:true}).waitFor();
|
||||
const accept=()=>page.getByRole('button',{name:'Accept the complete request',exact:true});
|
||||
assert(await accept().isDisabled());
|
||||
assert.equal(await page.locator('img,script').count(),0);
|
||||
await page.screenshot({path:path.join(directory,'review-desktop.png'),fullPage:true});
|
||||
checks.push('Entitled page escapes memo and packet markup');
|
||||
await accept().evaluate(button=>button.disabled=false);
|
||||
await accept().click();
|
||||
await page.getByText('Record all required highlight acknowledgments before accepting or declining.').waitFor();
|
||||
let state=await (await fetch(fixture.origin+'/_fixture/status')).json();
|
||||
assert.equal(state.entries,0);assert.equal(state.entry_posts,0);
|
||||
checks.push('Server refuses bypass of missing acknowledgments');
|
||||
await page.goBack();
|
||||
await page.getByLabel('I have reviewed this highlight').check();
|
||||
await page.getByRole('button',{name:'Record acknowledgments',exact:true}).click();
|
||||
assert(await accept().isEnabled());
|
||||
checks.push('Explicit acknowledgment unlocks the form');
|
||||
await page.locator('summary').filter({hasText:'Whole fixture document'}).click();
|
||||
const [download]=await Promise.all([
|
||||
page.waitForEvent('download'),page.getByRole('link',{name:'Download complete document',exact:true}).click()
|
||||
]);
|
||||
const bytes=await fs.readFile(await download.path());
|
||||
assert(bytes.includes(Buffer.from('Full fixture terms.')));
|
||||
checks.push('Complete attachment download stays behind entitlement');
|
||||
await page.locator('summary').filter({hasText:'Return for improvement'}).click();
|
||||
const returned=page.locator('form').filter({has:page.locator('select[name="reason"]')});
|
||||
await returned.locator('select').selectOption('clarification_needed');
|
||||
await returned.locator('textarea').fill('Synthetic browser return: clarify the fixture scope.');
|
||||
await returned.getByRole('button',{name:'Return for improvement',exact:true}).click();
|
||||
await page.getByText('Synthetic browser return: clarify the fixture scope.').waitFor();
|
||||
state=await (await fetch(fixture.origin+'/_fixture/status')).json();assert.equal(state.entries,0);
|
||||
checks.push('Return records a memo response without an engine entry');
|
||||
await page.setViewportSize({width:390,height:844});
|
||||
assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1));
|
||||
await page.screenshot({path:path.join(directory,'review-mobile.png'),fullPage:true});
|
||||
checks.push('Mobile review fits the viewport');
|
||||
await page.setViewportSize({width:1360,height:1000});
|
||||
await accept().click();
|
||||
await page.getByText('Approval entry recorded.',{exact:true}).waitFor();
|
||||
assert.equal(await page.getByRole('button',{name:'Accept the complete request',exact:true}).count(),0);
|
||||
await page.screenshot({path:path.join(directory,'recorded-desktop.png'),fullPage:true});
|
||||
await page.reload();await page.getByText('Approval entry recorded.',{exact:true}).waitFor();
|
||||
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'));
|
||||
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');
|
||||
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'));
|
||||
state=await (await fetch(fixture.origin+'/_fixture/status')).json();
|
||||
assert.equal(state.policy_outcomes.at(-1),'caller_refused');assert.equal(state.entries,1);
|
||||
checks.push('Real workload caller refusal hides review content');
|
||||
await page.goto(fixture.public_origin+'/');
|
||||
await page.getByRole('button',{name:'Sign out',exact:true}).click();
|
||||
await page.getByRole('link',{name:'Sign in with KeyCape'}).waitFor();
|
||||
assert(!(await context.cookies()).some(c=>c.name==='__Host-infd-session'));
|
||||
assert(formOrigins.every(form=>form.origin===fixture.public_origin));
|
||||
checks.push('Sign-out clears the session; all forms carry the exact origin');
|
||||
assert.deepEqual(errors,[]);
|
||||
checks.push('No page errors or injected script execution');
|
||||
const receipt={fixture_only:true,browser:'Chromium',checks_passed:checks.length,checks,
|
||||
fixture_transport:'real HTTPS on ephemeral ports; synthetic issuer and TokenReview',
|
||||
native_human_login_proven:false,native_policy_admitted:false,factory_attempts:0,paid_model_calls:0,
|
||||
final_component_state:state};
|
||||
await fs.writeFile(path.join(directory,'browser-result.json'),JSON.stringify(receipt,null,2)+'\n');
|
||||
process.stdout.write(JSON.stringify({checks_passed:checks.length,entries:state.entries,entry_posts:state.entry_posts,fixture_only:true})+'\n');
|
||||
} finally {
|
||||
if(checks.length<12 && context.pages()[0]) {
|
||||
const page=context.pages()[0], url=new URL(page.url());
|
||||
await fs.writeFile(path.join(directory,'browser-debug.json'),JSON.stringify({
|
||||
path:url.pathname,body:await page.locator('body').innerText(),navigation,formOrigins,
|
||||
cookie_names:(await context.cookies()).map(c=>c.name),errors},null,2));
|
||||
await page.screenshot({path:path.join(directory,'browser-debug.png'),fullPage:true});
|
||||
}
|
||||
await browser.close();
|
||||
await fetch(fixture.origin+'/_fixture/stop').catch(()=>{});
|
||||
}
|
||||
58
tests/fixtures/review-policy/fixtures.json
vendored
Normal file
58
tests/fixtures/review-policy/fixtures.json
vendored
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
[
|
||||
{
|
||||
"id": "synthetic-allow",
|
||||
"request": {
|
||||
"id": "fixture-pin",
|
||||
"tenant": "tenant:platform",
|
||||
"subject": {
|
||||
"id": "human-fixture",
|
||||
"type": "human",
|
||||
"tenant": "tenant:platform"
|
||||
},
|
||||
"action": "read",
|
||||
"resource": {
|
||||
"id": "memo:memo-1",
|
||||
"type": "decision-memo",
|
||||
"system": "informed-decision",
|
||||
"tenant": "tenant:platform"
|
||||
},
|
||||
"context": {
|
||||
"memo_version": 1,
|
||||
"approval_id": "fixture",
|
||||
"approval_binding_digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
},
|
||||
"policy_version": "v1"
|
||||
},
|
||||
"expect": {
|
||||
"effect": "allow"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "synthetic-refusal",
|
||||
"request": {
|
||||
"id": "fixture-pin",
|
||||
"tenant": "tenant:platform",
|
||||
"subject": {
|
||||
"id": "human-fixture",
|
||||
"type": "human",
|
||||
"tenant": "tenant:platform"
|
||||
},
|
||||
"action": "consume",
|
||||
"resource": {
|
||||
"id": "memo:memo-1",
|
||||
"type": "decision-memo",
|
||||
"system": "informed-decision",
|
||||
"tenant": "tenant:platform"
|
||||
},
|
||||
"context": {
|
||||
"memo_version": 1,
|
||||
"approval_id": "fixture",
|
||||
"approval_binding_digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
},
|
||||
"policy_version": "v1"
|
||||
},
|
||||
"expect": {
|
||||
"effect": "deny"
|
||||
}
|
||||
}
|
||||
]
|
||||
6
tests/fixtures/review-policy/pin.json
vendored
Normal file
6
tests/fixtures/review-policy/pin.json
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"package": "informed-decision.fixture",
|
||||
"version": "v1",
|
||||
"package_digest": "sha256:bf39cd7fb33c3db6ba5adb3c036911216e35ef03c1168034e38fe741df67653a",
|
||||
"fixture_only": true
|
||||
}
|
||||
50
tests/fixtures/review-policy/policy.md
vendored
Normal file
50
tests/fixtures/review-policy/policy.md
vendored
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
---
|
||||
id: informed-decision.fixture
|
||||
name: Synthetic review API contract fixture
|
||||
namespace: informed-decision:decision-memo
|
||||
version: v1
|
||||
status: ready
|
||||
package: flexauth.informed_decision.fixture
|
||||
allow_ttl: 60s
|
||||
actions: [read, acknowledge, accept, return, discuss, decline]
|
||||
owner: fixture-only
|
||||
fixtures: [fixtures.json]
|
||||
caring:
|
||||
profile: caring-0.4.0-rc2
|
||||
enforce: false
|
||||
activation:
|
||||
mode: local
|
||||
---
|
||||
|
||||
# Synthetic component fixture, never a production policy
|
||||
|
||||
Only the locally signed human-fixture and memo:memo-1 can exercise this test.
|
||||
No production approver, group, mandate or permission is established here.
|
||||
No presentation claim, view hash, acknowledgment or local outcome is input.
|
||||
|
||||
```rego
|
||||
import rego.v1
|
||||
|
||||
decision := {"effect": "allow", "reason": "synthetic_review_fixture"} if {
|
||||
input.tenant == "tenant:platform"
|
||||
input.subject.id == "human-fixture"
|
||||
input.subject.type == "human"
|
||||
input.resource.system == "informed-decision"
|
||||
input.resource.type == "decision-memo"
|
||||
input.resource.id == "memo:memo-1"
|
||||
input.action in {"read", "acknowledge", "accept", "return", "discuss", "decline"}
|
||||
input.context.approval_id == "fixture"
|
||||
} else := {"effect": "deny", "reason": "fixture_scope_refused"} if {
|
||||
true
|
||||
}
|
||||
```
|
||||
|
||||
```rego test
|
||||
package flexauth.informed_decision.fixture_test
|
||||
import rego.v1
|
||||
import data.flexauth.informed_decision.fixture
|
||||
|
||||
test_unknown_request_denied if {
|
||||
fixture.decision.effect == "deny" with input as {}
|
||||
}
|
||||
```
|
||||
18
tests/fixtures/review-policy/registry.json
vendored
Normal file
18
tests/fixtures/review-policy/registry.json
vendored
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"subjects": [
|
||||
{
|
||||
"id": "human-fixture",
|
||||
"type": "Human",
|
||||
"display_name": "Synthetic browser reviewer",
|
||||
"organization_relation": "ServiceProvider",
|
||||
"roles": [
|
||||
"Operator"
|
||||
],
|
||||
"groups": [],
|
||||
"tenant": "tenant:platform",
|
||||
"metadata": {
|
||||
"source": "synthetic-contract-fixture"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
79
tests/review_components.py
Normal file
79
tests/review_components.py
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
"""Disposable actual Flex Auth HTTP process, with synthetic Kubernetes custody.
|
||||
|
||||
The evaluator and inbound caller authenticator are real. TokenReview and its
|
||||
identities are fixtures; this does not admit any production caller or policy.
|
||||
"""
|
||||
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import socket
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.x509.oid import NameOID
|
||||
|
||||
from informed_decision.http_transport import JSONTransport, TransportError
|
||||
from informed_decision.policy import PolicyClient
|
||||
|
||||
FIXTURE = Path(__file__).parent / 'fixtures/review-policy'
|
||||
|
||||
|
||||
@contextmanager
|
||||
def flex_component(binary, directory, signing_key):
|
||||
tokens={'synthetic-workload-token':'system:serviceaccount:informed-decision:review',
|
||||
'synthetic-wrong-caller':'system:serviceaccount:other:other'}
|
||||
calls=[]
|
||||
class Reviewer(BaseHTTPRequestHandler):
|
||||
def log_message(self,*args):pass
|
||||
def do_POST(self):
|
||||
body=json.loads(self.rfile.read(int(self.headers['Content-Length'])))
|
||||
token=body['spec']['token'];calls.append({'known':token in tokens,'audiences':body['spec']['audiences']})
|
||||
valid=(self.path=='/apis/authentication.k8s.io/v1/tokenreviews'
|
||||
and self.headers.get('Authorization')=='Bearer synthetic-reviewer'
|
||||
and body['spec']['audiences']==['flex-auth'] and token in tokens)
|
||||
raw=json.dumps({'status':{'authenticated':valid,'audiences':['flex-auth'] if valid else [],
|
||||
'user':{'username':tokens.get(token,'')}}}).encode()
|
||||
self.send_response(201);self.send_header('Content-Type','application/json');self.end_headers();self.wfile.write(raw)
|
||||
server=ThreadingHTTPServer(('127.0.0.1',0),Reviewer)
|
||||
thread=threading.Thread(target=server.serve_forever,daemon=True);thread.start()
|
||||
now=datetime.now(timezone.utc)
|
||||
name=x509.Name([x509.NameAttribute(NameOID.COMMON_NAME,'synthetic-tokenreview-fixture')])
|
||||
cert=(x509.CertificateBuilder().subject_name(name).issuer_name(name).public_key(signing_key.public_key())
|
||||
.serial_number(x509.random_serial_number()).not_valid_before(now-timedelta(minutes=1))
|
||||
.not_valid_after(now+timedelta(hours=1)).add_extension(x509.BasicConstraints(ca=True,path_length=None),critical=True)
|
||||
.sign(signing_key,hashes.SHA256()))
|
||||
ca=directory/'fixture-ca.pem';ca.write_bytes(cert.public_bytes(serialization.Encoding.PEM))
|
||||
reviewer=directory/'reviewer-token';reviewer.write_text('synthetic-reviewer');reviewer.chmod(0o600)
|
||||
with socket.socket() as listener:
|
||||
listener.bind(('127.0.0.1',0));port=listener.getsockname()[1]
|
||||
origin=f'http://127.0.0.1:{port}'
|
||||
process=subprocess.Popen([str(binary),'serve','--addr',f'127.0.0.1:{port}',
|
||||
'--registry',str(FIXTURE/'registry.json'),'--policy',str(FIXTURE/'policy.md'),
|
||||
'--caller-auth-mode','enforce','--caller-binding','informed-decision=system:serviceaccount:informed-decision:review',
|
||||
'--caller-kubernetes-url',f'http://127.0.0.1:{server.server_port}',
|
||||
'--caller-reviewer-token-file',str(reviewer),'--caller-ca-file',str(ca)],stdout=subprocess.DEVNULL,stderr=subprocess.PIPE)
|
||||
try:
|
||||
transport=JSONTransport(allow_internal_http=True)
|
||||
for _ in range(100):
|
||||
if process.poll() is not None:
|
||||
raise RuntimeError('fixture evaluator exited: '+process.stderr.read().decode()[:1000])
|
||||
try:
|
||||
if transport.request('GET',origin+'/healthz')[0]==200:break
|
||||
except TransportError:pass
|
||||
time.sleep(.05)
|
||||
else:raise RuntimeError('fixture evaluator did not start')
|
||||
pin=json.loads((FIXTURE/'pin.json').read_text());pin.pop('fixture_only')
|
||||
client=PolicyClient(origin,lambda:'synthetic-workload-token',allow_internal_http=True,**pin)
|
||||
yield client,calls
|
||||
finally:
|
||||
process.terminate()
|
||||
try:process.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:process.kill();process.wait(timeout=5)
|
||||
process.stderr.close();server.shutdown();server.server_close();thread.join(timeout=5)
|
||||
|
|
@ -274,7 +274,7 @@ def test_issuer_error_and_duplicate_params_do_not_leak_or_exchange(login):
|
|||
|
||||
|
||||
@pytest.mark.parametrize("origin,csrf,expected", [(ORIGIN, "correct", 303), (ORIGIN, "wrong", 403),
|
||||
(None, "correct", 403), ("https://attacker.test", "correct", 403)])
|
||||
(None, "correct", 403), ("null", "correct", 403), ("https://attacker.test", "correct", 403)])
|
||||
def test_logout_requires_origin_and_session_csrf(login, origin, csrf, expected):
|
||||
sid = finish(login)
|
||||
token = login[0].session(sid).csrf if csrf == "correct" else "wrong"
|
||||
|
|
@ -284,6 +284,12 @@ def test_logout_requires_origin_and_session_csrf(login, origin, csrf, expected):
|
|||
assert bool(login[0].session(sid)) == (expected != 303)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path,policy", [("/", "same-origin"), ("/review", "same-origin"),
|
||||
("/auth/callback", "no-referrer"), ("/presentations/pres-fixture/packet/0", "no-referrer")])
|
||||
def test_browser_referrer_policy_preserves_form_origin_and_protects_auth_urls(login, path, policy):
|
||||
assert dict(call(App(login[0]), path)["headers"])["Referrer-Policy"] == policy
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path,method,expected", [("/healthz", "GET", 200), ("/readyz", "GET", 503),
|
||||
("/approvals/approval-fixture", "GET", 404), ("/approvals/approval-fixture/accept", "POST", 404),
|
||||
("/v1/approvals/approval-fixture/entries", "POST", 404), ("/auth/logout", "GET", 404)])
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@ def upstream():
|
|||
"/ok": (200, b'{"ok":true}'), "/refuse": (403, b'{"error":"forbidden"}'),
|
||||
"/broken": (200, b"broken"), "/array": (200, b"[]"),
|
||||
"/large": (200, b" " * 262145),
|
||||
"/plain-refuse": (403, b"forbidden secret-sentinel"),
|
||||
"/large-refuse": (401, b"x" * 262145),
|
||||
}.get(self.path, (500, b"{}"))
|
||||
self.send_response(status)
|
||||
self.end_headers()
|
||||
|
|
@ -60,3 +62,9 @@ def test_json_success_and_typed_refusal_preserved():
|
|||
client = JSONTransport(allow_internal_http=True)
|
||||
assert client.request("GET", origin + "/ok") == (200, {"ok": True})
|
||||
assert client.request("GET", origin + "/refuse") == (403, {"error": "forbidden"})
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path,status", [("/plain-refuse",403),("/large-refuse",401)])
|
||||
def test_http_caller_refusal_is_not_relabelled_as_transport_outage(path,status):
|
||||
with upstream() as (origin,calls):
|
||||
assert JSONTransport(allow_internal_http=True).request("GET",origin+path)==(status,{})
|
||||
|
|
|
|||
114
tests/test_policy_client.py
Normal file
114
tests/test_policy_client.py
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
import copy
|
||||
from dataclasses import replace
|
||||
from datetime import datetime, timezone
|
||||
import json
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from informed_decision.http_transport import TransportError
|
||||
from informed_decision.oidc import HumanSession
|
||||
from informed_decision.policy import CONTRACT, PolicyClient, PolicyError, build_request, submitted_digest
|
||||
from informed_decision.provenance import Claim, Route
|
||||
from test_skeleton import make_memo
|
||||
|
||||
PIN = "sha256:" + "b" * 64
|
||||
STAMP = lambda t: datetime.fromtimestamp(t, timezone.utc).isoformat()
|
||||
|
||||
|
||||
def human():
|
||||
return HumanSession("human-fixture", Claim("tenant:platform", Route.REGISTRATION),
|
||||
Claim("human", Route.AUTHENTICATION), {"at": int(time.time()) - 20, "level": "aal2", "mfa": True,
|
||||
"methods": ["pwd", "otp"], "source": "key-cape"}, time.time()+600, "synthetic-browser-token", roles=("Reviewer",))
|
||||
|
||||
|
||||
def envelope(request):
|
||||
now = time.time()
|
||||
return {"id": "decision-fixture", "contract_version": CONTRACT, "request_id": request["id"],
|
||||
"effect": "allow", "matched_policy_version": "v1", "subject": copy.deepcopy(request["subject"]),
|
||||
"resource": copy.deepcopy(request["resource"]),
|
||||
"binding": {**copy.deepcopy({k:request[k] for k in ("tenant","subject","action","resource","context")}),
|
||||
"request_digest": "sha256:" + "e" * 64, "submitted_request_digest": submitted_digest(request)},
|
||||
"lifetime": {"kind":"ttl", "not_before":STAMP(now-1), "expires_at":STAMP(now+60)},
|
||||
"provenance": {"evaluator":"flex-auth/local", "mode":"standalone", "policy_package":"informed-decision.fixture",
|
||||
"policy_version":"v1", "policy_package_digest":PIN, "registry_snapshot_digest":"sha256:"+"c"*64,
|
||||
"decision_time":STAMP(now)}}
|
||||
|
||||
|
||||
class PolicyTransport:
|
||||
def __init__(self): self.calls=[]; self.change=lambda d: None; self.status=200
|
||||
def request(self, method, url, *, headers=None, body=None):
|
||||
self.calls.append((method,url,headers,json.loads(body)))
|
||||
data=envelope(json.loads(body)); self.change(data)
|
||||
return self.status, data
|
||||
|
||||
|
||||
def policy(transport=None):
|
||||
return PolicyClient("https://policy.test", lambda:"synthetic-workload-token", package="informed-decision.fixture",
|
||||
version="v1", package_digest=PIN, transport=transport or PolicyTransport())
|
||||
|
||||
|
||||
def request():
|
||||
return build_request(human(), make_memo(approval_binding_digest="sha256:"+"a"*64), "read", "v1")
|
||||
|
||||
|
||||
def test_request_preserves_identity_routes_and_excludes_presentation_claims():
|
||||
req=request(); attributes=req["subject"]["attributes"]
|
||||
assert attributes["tenant_source"]=="registration-supplied" and attributes["principal_type_source"]=="authentication-derived"
|
||||
assert attributes["roles"]==["Reviewer"]
|
||||
assert set(req["context"])=={"memo_version","approval_id","approval_binding_digest"}
|
||||
assert "synthetic-browser-token" not in json.dumps(req)
|
||||
assert not any(k in json.dumps(req) for k in ["view_hash","presentation_id","acked_highlight_ids"])
|
||||
|
||||
|
||||
def test_fresh_exact_enriched_decision_and_separate_workload_identity():
|
||||
client=policy();req=request();observed=client.check(req)
|
||||
observed.require_current(time.time())
|
||||
data=json.loads(observed.decision_json)
|
||||
assert data["binding"]["request_digest"]!=data["binding"]["submitted_request_digest"]
|
||||
assert client.transport.calls[0][2]["Authorization"]=="Bearer synthetic-workload-token"
|
||||
assert observed.usable_until<=time.time()+30
|
||||
with pytest.raises(PolicyError):observed.require_current(time.time()+31)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mutate", [
|
||||
lambda d:d.update(contract_version="unknown"), lambda d:d.update(request_id="other"),
|
||||
lambda d:d["binding"].pop("submitted_request_digest"),
|
||||
lambda d:d["binding"].update(submitted_request_digest="sha256:"+"0"*64),
|
||||
lambda d:d["binding"].update(action="accept"), lambda d:d["binding"]["subject"].update(id="other"),
|
||||
lambda d:d["binding"]["resource"].update(system="other"),
|
||||
lambda d:d["binding"]["context"].update(memo_version=99),
|
||||
lambda d:d["binding"].update(tenant="tenant:other"), lambda d:d["resource"].update(id="memo:other"),
|
||||
lambda d:d["provenance"].update(policy_package="other"), lambda d:d["provenance"].update(policy_version="other"),
|
||||
lambda d:d["provenance"].update(policy_package_digest="sha256:"+"0"*64),
|
||||
lambda d:d.update(matched_policy_version="other"), lambda d:d["provenance"].pop("registry_snapshot_digest"),
|
||||
lambda d:d["provenance"].update(decision_time=STAMP(time.time()-300)),
|
||||
lambda d:d["lifetime"].update(expires_at=STAMP(time.time()-1)),
|
||||
lambda d:d["lifetime"].update(not_before=STAMP(time.time()+300)),
|
||||
lambda d:d["lifetime"].update(kind="forever"), lambda d:d.update(lifetime={}),
|
||||
lambda d:d.update(obligations=[{"type":"unimplemented"}]), lambda d:d.update(obligations={}),
|
||||
lambda d:d.update(obligations=None), lambda d:d.update(effect="redact"),
|
||||
lambda d:d.update(effect="audit_only"), lambda d:d.update(effect="not_applicable"),
|
||||
lambda d:d.update(effect="deny"), lambda d:d.update(diagnostics={"bad":float("nan")}),
|
||||
])
|
||||
def test_unusable_decision_never_allows(mutate):
|
||||
transport=PolicyTransport();transport.change=mutate
|
||||
observed=policy(transport).check(request())
|
||||
with pytest.raises(PolicyError):observed.require_current(time.time())
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status", [400,401,403,404,409,500,503])
|
||||
def test_upstream_errors_never_persist_response_body(status):
|
||||
transport=PolicyTransport();transport.status=status
|
||||
transport.change=lambda d:d.update(reason="secret-sentinel",diagnostics={"token":"secret-sentinel"})
|
||||
observed=policy(transport).check(request())
|
||||
assert observed.decision_json is None and "secret-sentinel" not in repr(observed)
|
||||
with pytest.raises(PolicyError):observed.require_current(time.time())
|
||||
|
||||
|
||||
def test_transport_and_missing_caller_have_typed_observations():
|
||||
class Broken:
|
||||
def request(self,*a,**kw):raise TransportError("secret-sentinel")
|
||||
client=policy(Broken());assert client.check(request()).outcome=="policy_unavailable"
|
||||
client.token_provider=lambda:None
|
||||
assert client.check(request()).outcome=="caller_unavailable"
|
||||
76
tests/test_review_component.py
Normal file
76
tests/test_review_component.py
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
from dataclasses import replace
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from informed_decision.disposition import Verb
|
||||
from informed_decision.policy import build_request
|
||||
from informed_decision.runtime import AuditPump
|
||||
from informed_decision.web import SESSION_COOKIE
|
||||
from test_browser_auth import call, signing_key
|
||||
from test_approval_component import component
|
||||
from test_review_controller import review, opened
|
||||
from test_durable_component import receiver
|
||||
from review_components import flex_component
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pdp(tmp_path,signing_key):
|
||||
binary=os.environ.get('INFD_FLEX_AUTH_BINARY')
|
||||
if not binary:pytest.skip('set INFD_FLEX_AUTH_BINARY to test the actual evaluator and caller boundary')
|
||||
assert Path(binary).is_file()
|
||||
with flex_component(binary,tmp_path,signing_key) as value:yield value
|
||||
|
||||
|
||||
def test_actual_pdp_enrichment_and_workload_caller_boundary(pdp,review):
|
||||
client,calls=pdp;controller,session,_,memo,*_=review
|
||||
request=build_request(session,memo,'read',client.version)
|
||||
observation=client.check(request);assert observation.outcome=='allow',observation
|
||||
decision=json.loads(observation.decision_json)
|
||||
assert decision['binding']['request_digest']!=decision['binding']['submitted_request_digest']
|
||||
assert calls[-1]=={'known':True,'audiences':['flex-auth']}
|
||||
client.token_provider=lambda:'synthetic-wrong-caller'
|
||||
assert client.check(request).outcome=='caller_refused'
|
||||
client.token_provider=lambda:session.access_token
|
||||
assert client.check(request).outcome=='caller_refused' # Browser JWT is never a workload credential.
|
||||
|
||||
|
||||
def test_actual_request_digest_handles_go_string_escaping(pdp,review):
|
||||
client,_=pdp;_,session,_,memo,*_=review
|
||||
session=replace(session,roles=('Reviewer <x>& ü \u2028 \u2029',))
|
||||
observation=client.check(build_request(session,memo,'read',client.version))
|
||||
assert observation.outcome=='allow',observation
|
||||
|
||||
|
||||
def test_actual_policy_engine_and_independent_audit_receive_browser_act(pdp,review,receiver):
|
||||
controller,session,app,memo,engine,transport=review;controller.policy=pdp[0]
|
||||
page=opened(review);p=page.presentation
|
||||
controller.acknowledge(session,p.id,['h-1'])
|
||||
controller.act(session,p.id,Verb.ACCEPT,operation_id='real-component-click')
|
||||
assert len(engine.get('fixture').entries)==1
|
||||
sink,receiver_transport,_=receiver
|
||||
pump=AuditPump(controller.store,sink);pump.tick();assert pump.ready()
|
||||
assert all(row['state']=='delivered' for row in controller.store.outbox())
|
||||
observations=controller.store.policy_observations()
|
||||
assert len(observations)==3 and all(row['outcome']=='allow' and row['decision_attributable']==0 for row in observations)
|
||||
response=call(app,'/presentations/'+p.id,cookie=SESSION_COOKIE+'=fixture-session')
|
||||
assert 'Approval entry recorded.' in response['body']
|
||||
event=controller.store.evidence()[0]
|
||||
status,body=receiver_transport.request('GET','https://audit.test/v1/events/'+event['id'],headers={'Authorization':'Bearer synthetic-auditor-token'})
|
||||
assert status==200 and body['details']['data']['view_hash']==p.view_hash
|
||||
assert 'private-brief-sentinel' not in json.dumps(body)
|
||||
report=json.loads((controller.store.path.parent/'audit-reconciliation.json').read_text())
|
||||
assert report['source_time_basis']=='occurred_at' and report['receiver_time_basis']=='accepted_at'
|
||||
assert not report['completeness_proven'] and not report['automatic_loss_finding']
|
||||
|
||||
|
||||
def test_actual_policy_refusal_prevents_content_and_engine_read(pdp,review):
|
||||
controller,session,app,memo,engine,transport=review;controller.policy=pdp[0]
|
||||
controller.store.save_memo(replace(memo,id='not-assigned-in-fixture'))
|
||||
before=len(transport.calls)
|
||||
response=call(app,'/review',query='memo_id=not-assigned-in-fixture',cookie=SESSION_COOKIE+'=fixture-session')
|
||||
assert response['status']==403 and 'private-brief-sentinel' not in response['body']
|
||||
assert len(transport.calls)==before and controller.store.evidence()==[]
|
||||
254
tests/test_review_controller.py
Normal file
254
tests/test_review_controller.py
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
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'<script>private-packet-sentinel</script>\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 <img src=x onerror=alert(1)>')
|
||||
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 '<img src=x' not in response['body']
|
||||
assert '<script>private-packet-sentinel' in response['body']
|
||||
|
||||
|
||||
@pytest.mark.parametrize('fault',['origin','csrf','duplicate','content-type','field'])
|
||||
def test_post_form_refusals_precede_policy_and_side_effects(review,fault):
|
||||
controller,session,app,*_=review;p=opened(review).presentation
|
||||
body=urlencode({'csrf':session.csrf,'h0':'h-1'}).encode();origin=ORIGIN;ctype='application/x-www-form-urlencoded'
|
||||
if fault=='origin':origin='https://other.test'
|
||||
if fault=='csrf':body=b'csrf=wrong&h0=h-1'
|
||||
if fault=='duplicate':body+=b'&h0=other'
|
||||
if fault=='content-type':ctype='application/json'
|
||||
if fault=='field':body+=b'&subject=other'
|
||||
before=len(controller.policy.transport.calls)
|
||||
r=call(app,'/presentations/'+p.id+'/ack','POST',cookie=SESSION_COOKIE+'=fixture-session',body=body,origin=origin,content_type=ctype)
|
||||
assert r['status'] in (400,403) and len(controller.policy.transport.calls)==before
|
||||
assert not controller.store.presentation(p.id).acked_highlight_ids
|
||||
|
||||
|
||||
def test_highlights_gate_accept_then_one_real_entry_and_original_view(review):
|
||||
controller,session,app,memo,engine,transport=review;p=opened(review).presentation
|
||||
op=str(uuid.uuid4())
|
||||
assert post(review,p,'act',verb='accept',operation_id=op)['status']==409
|
||||
assert not engine.get('fixture').entries
|
||||
assert post(review,p,'ack',h0='h-1')['status']==303
|
||||
assert post(review,p,'act',verb='accept',operation_id=op)['status']==303
|
||||
assert len(engine.get('fixture').entries)==1
|
||||
posts=sum(method=='POST' for method,_ in transport.calls)
|
||||
assert post(review,p,'act',verb='accept',operation_id=op)['status']==303
|
||||
assert sum(method=='POST' for method,_ in transport.calls)==posts==1
|
||||
page=controller.open(session,memo.id)
|
||||
assert page.presentation.id==p.id and page.intent['state']=='confirmed'
|
||||
response=call(app,'/presentations/'+p.id,cookie=SESSION_COOKIE+'=fixture-session')
|
||||
assert 'Approval entry recorded.' in response['body']
|
||||
assert 'Accept the complete request</button>' 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</button>' 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()==[]
|
||||
82
tests/test_review_runtime.py
Normal file
82
tests/test_review_runtime.py
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
from dataclasses import replace
|
||||
import json
|
||||
import sqlite3
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from informed_decision.runtime import Runtime, AuditPump, token_file
|
||||
from informed_decision.store import Store
|
||||
from test_policy_client import policy,request
|
||||
from test_durable_store import storage,present
|
||||
from test_durable_component import receiver
|
||||
|
||||
|
||||
def test_schema_one_upgrade_preserves_existing_evidence(storage):
|
||||
store,memo=storage;p=present(store,memo)
|
||||
before=store.evidence()
|
||||
with sqlite3.connect(store.path) as db:
|
||||
db.execute('DROP TABLE policy_observations');db.execute('PRAGMA user_version=1')
|
||||
reopened=Store(store.path)
|
||||
assert reopened.retrieve_presentation(p.id)[0]==memo and reopened.evidence()==before
|
||||
assert reopened.policy_observations()==[]
|
||||
observation=policy().check(request());reopened.observe_policy(observation)
|
||||
assert Store(store.path).policy_observations()[0]['decision_attributable']==0
|
||||
with sqlite3.connect(store.path) as db:
|
||||
for statement in ['DELETE FROM policy_observations',"UPDATE policy_observations SET outcome='allow'"]:
|
||||
with pytest.raises(sqlite3.IntegrityError):db.execute(statement)
|
||||
|
||||
|
||||
def test_caller_token_is_reread_on_rotation_and_bounded(tmp_path):
|
||||
path=tmp_path/'token';path.write_text('synthetic-first\n');read=token_file(path)
|
||||
assert read()=='synthetic-first'
|
||||
path.write_text('synthetic-rotated');assert read()=='synthetic-rotated'
|
||||
path.write_bytes(b'x'*32769)
|
||||
with pytest.raises(ValueError):read()
|
||||
with pytest.raises(ValueError):token_file('relative')
|
||||
|
||||
|
||||
def config(tmp_path):
|
||||
private=tmp_path/'private';private.mkdir(mode=0o700)
|
||||
path=private/'runtime.json'
|
||||
data={'schema':'informed-decision.review-runtime.v1','evidence_db':str(private/'review.sqlite'),
|
||||
'approval_origin':'https://approval.test','policy':{'origin':'https://policy.test','package':'fixture',
|
||||
'version':'v1','package_digest':'sha256:'+'a'*64,'caller_token_file':str(private/'unprovisioned-caller')},
|
||||
'audit':{'origin':'https://audit.test','sender_token_file':str(private/'unprovisioned-sender')}}
|
||||
path.write_text(json.dumps(data));path.chmod(0o600)
|
||||
return path,data
|
||||
|
||||
|
||||
def test_config_load_does_not_provision_or_read_credentials(tmp_path):
|
||||
path,data=config(tmp_path);runtime=Runtime.from_file(path)
|
||||
assert not runtime.pump.ready()
|
||||
assert not (path.parent/'unprovisioned-caller').exists()
|
||||
runtime.pump.tick();assert not runtime.pump.ready()
|
||||
assert all(r['state']=='blocked' for r in runtime.controller.store.outbox())
|
||||
|
||||
|
||||
@pytest.mark.parametrize('fault',['permissions','symlink','unknown-key','relative-store','missing-pin','unsafe-origin'])
|
||||
def test_unsafe_or_incomplete_config_refused(tmp_path,fault):
|
||||
path,data=config(tmp_path)
|
||||
if fault=='permissions':path.chmod(0o644)
|
||||
elif fault=='symlink':
|
||||
linked=path.parent/'link.json';linked.symlink_to(path);path=linked
|
||||
elif fault=='unknown-key':data['allow_everyone']=True
|
||||
elif fault=='relative-store':data['evidence_db']='relative.db'
|
||||
elif fault=='missing-pin':data['policy']['package_digest']=''
|
||||
elif fault=='unsafe-origin':data['approval_origin']='http://public.example.com'
|
||||
if fault not in ('permissions','symlink'):path.write_text(json.dumps(data))
|
||||
with pytest.raises(ValueError):Runtime.from_file(path)
|
||||
|
||||
|
||||
def test_pump_retains_blocked_delivery_and_requires_repair(storage,receiver):
|
||||
store,memo=storage;present(store,memo)
|
||||
sink,_,_=receiver;original=sink.token_provider;sink.token_provider=lambda:'synthetic-wrong-token'
|
||||
pump=AuditPump(store,sink);pump.tick();assert not pump.ready()
|
||||
ids=[r['id'] for r in store.outbox() if r['state']=='blocked'];assert ids
|
||||
sink.token_provider=original
|
||||
pump.tick();assert not pump.ready() # No implicit credential repair/requeue.
|
||||
for ident in ids:store.requeue_blocked(ident)
|
||||
pump.tick();assert pump.ready()
|
||||
assert (store.path.parent/'audit-reconciliation.json').stat().st_mode & 0o777 == 0o600
|
||||
pump.clock=lambda:time.time()+91;assert not pump.ready()
|
||||
Loading…
Add table
Add a link
Reference in a new issue