fix: harden Human Gates review flow
This commit is contained in:
1 parent
364fc60116
commit
dbb8ff4d4e
8 files changed
+130
-16
No files matched your search
@@ -52,7 +52,7 @@ async def test_intake_list_and_detail_are_account_bound_and_no_store(gate_api):
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_decision_requires_revision_and_returns_durable_receipt(gate_api):
|
||||
gate = gate_api.intake("timmy", CANDIDATE, idempotency_key="run-9")
|
||||
gate = gate_api.intake("1:timmy", CANDIDATE, idempotency_key="run-9")
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
payload = {"expected_revision": gate["revision"], "decision": "release", "reason": "", "override_reason": "", "checklist": CHECKLIST}
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
@@ -68,6 +68,29 @@ async def test_decision_requires_revision_and_returns_durable_receipt(gate_api):
|
||||
assert all(response.headers["cache-control"] == "no-store" for response in (decided, repeated, receipt, stale))
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_recycled_login_cannot_read_another_principal_gates(monkeypatch, tmp_path):
|
||||
store = HumanGateStore(tmp_path / "gates.sqlite3", clock=lambda: 100)
|
||||
monkeypatch.setattr(main, "_human_gate_store", lambda: store, raising=False)
|
||||
principal = {"id": 1, "login": "timmy"}
|
||||
|
||||
async def identity():
|
||||
return principal.copy()
|
||||
|
||||
monkeypatch.setattr(main, "current_user", identity)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
created = await client.post(
|
||||
"/api/v1/human-gates/intake", json=CANDIDATE,
|
||||
headers={"Idempotency-Key": "principal-1"},
|
||||
)
|
||||
principal["id"] = 2
|
||||
listing = await client.get("/api/v1/human-gates")
|
||||
|
||||
assert created.status_code == 201
|
||||
assert listing.json()["pending_count"] == 0
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gate_store_failure_is_sanitized_no_store(monkeypatch):
|
||||
def unavailable():
|
||||
@@ -91,7 +114,7 @@ async def test_gate_store_failure_is_sanitized_no_store(monkeypatch):
|
||||
@pytest.mark.anyio
|
||||
async def test_gate_mutations_require_idempotency_key_and_validate_override(gate_api):
|
||||
failing = {**CANDIDATE, "candidate_hash": "fail123", "checks": [{"name": "browser", "state": "failure", "required": True}]}
|
||||
gate = gate_api.intake("timmy", failing, idempotency_key="run-fail")
|
||||
gate = gate_api.intake("1:timmy", failing, idempotency_key="run-fail")
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
missing_key = await client.post("/api/v1/human-gates/intake", json=CANDIDATE)
|
||||
|
||||
@@ -102,7 +102,20 @@ def test_hold_requires_reason_and_release_requires_checklist_and_override_for_un
|
||||
assert receipt["checklist"] == {}
|
||||
|
||||
|
||||
def test_same_hash_cannot_be_redefined_by_a_new_idempotency_key(tmp_path):
|
||||
def test_same_hash_update_coalesces_one_card_and_preserves_history(tmp_path):
|
||||
store = HumanGateStore(tmp_path / "gates.sqlite3", clock=iter([100, 101]).__next__)
|
||||
original = store.intake("timmy", candidate(check_state="pending"), idempotency_key="run-1")
|
||||
|
||||
updated = store.intake("timmy", candidate(check_state="success"), idempotency_key="run-2")
|
||||
|
||||
assert updated["id"] == original["id"]
|
||||
assert updated["revision"] == 2
|
||||
assert updated["checks"][0]["state"] == "success"
|
||||
assert updated["history"][-1]["action"] == "updated"
|
||||
assert store.list("timmy")["pending_count"] == 1
|
||||
|
||||
|
||||
def test_same_hash_identity_facts_cannot_be_redefined(tmp_path):
|
||||
store = HumanGateStore(tmp_path / "gates.sqlite3", clock=lambda: 100)
|
||||
store.intake("timmy", candidate(), idempotency_key="run-1")
|
||||
|
||||
|
||||
@@ -71,11 +71,49 @@ const gates=createHumanGates({storage:{getItem:()=>null,setItem(){}},getLogin:()
|
||||
assert output["posts"] == 0
|
||||
|
||||
|
||||
def test_offline_cache_is_scoped_to_immutable_account_identity():
|
||||
output = run_node(r"""
|
||||
const values=new Map(); const storage={getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)};
|
||||
let account='1:timmy'; const nodes={count:{},list:{},status:{},panel:{}};
|
||||
const gates=createHumanGates({storage,getLogin:()=> 'timmy',getAccountKey:()=>account,isOnline:()=>true,nodes,location:{hash:''},fetchJson:async()=>({pending_count:1,items:[{id:'g1',title:'Private',candidate_hash:'a1'}]})});
|
||||
(async()=>{await gates.load();account='2:timmy';process.stdout.write(JSON.stringify({keys:[...values.keys()],restored:gates.restoreCached()}));})();
|
||||
""")
|
||||
assert output["keys"] == ["stackchain.human-gates.v1:1:timmy"]
|
||||
assert output["restored"] is None
|
||||
|
||||
|
||||
def test_decision_retry_reuses_the_same_idempotency_key():
|
||||
output = run_node(r"""
|
||||
let attempts=0; const keys=[];
|
||||
const item={id:'g1',title:'Candidate',candidate_hash:'a1',revision:1,checks:[]};
|
||||
const gates=createHumanGates({storage:{getItem:()=>null,setItem(){}},getLogin:()=> 'timmy',isOnline:()=>true,nodes:{count:{},list:{},status:{},panel:{},detail:{}},location:{hash:''},fetchJson:async(path,options={})=>{if(options.method==='POST'){keys.push(options.headers['Idempotency-Key']);attempts++;if(attempts===1)throw new Error('network');return {receipt_id:'r1'}};return {pending_count:1,items:[item]};}});
|
||||
(async()=>{await gates.load();gates.reviewNext();const values={reason:'wait',checklist:{}};try{await gates.decideAndNext('hold',values)}catch(_){}await gates.decideAndNext('hold',values);process.stdout.write(JSON.stringify({keys}));})();
|
||||
""")
|
||||
assert len(output["keys"]) == 2
|
||||
assert output["keys"][0] == output["keys"][1]
|
||||
|
||||
|
||||
def test_selecting_a_queue_card_opens_that_exact_gate():
|
||||
output = run_node(r"""
|
||||
const details={
|
||||
g1:{id:'g1',title:'First',project:'p/one',candidate_hash:'a1',revision:1,checks:[]},
|
||||
g2:{id:'g2',title:'Second',project:'p/two',candidate_hash:'b2',revision:1,checks:[]},
|
||||
};
|
||||
const nodes={count:{},list:{},status:{},panel:{},detail:{innerHTML:''}};
|
||||
const gates=createHumanGates({storage:{getItem:()=>null,setItem(){}},getLogin:()=> 'timmy',isOnline:()=>true,nodes,location:{hash:''},fetchJson:async path=>path.includes('/g')?details[path.split('/').pop()]:{pending_count:2,items:Object.values(details)}});
|
||||
(async()=>{await gates.load();gates.reviewNext();gates.select('g2');await new Promise(resolve=>setTimeout(resolve,0));process.stdout.write(JSON.stringify({current:gates.current().id,html:nodes.detail.innerHTML}));})();
|
||||
""")
|
||||
assert output["current"] == "g2"
|
||||
assert "Second" in output["html"]
|
||||
assert "p/two" in output["html"]
|
||||
|
||||
|
||||
def test_human_gate_mobile_shell_and_deep_route_are_wired():
|
||||
index = INDEX.read_text()
|
||||
dashboard = DASHBOARD.read_text()
|
||||
assert 'id="human-gates"' in index
|
||||
assert 'id="human-gates-count"' in index
|
||||
assert 'Review next <span id="human-gates-count"' in index
|
||||
assert 'static/human-gates.js' in index
|
||||
assert "#/my-work/human-gates" in dashboard
|
||||
assert "createHumanGates" in dashboard
|
||||
Reference in new issue
Block a user