pdac-genomics-agent-dev / tests /test_ui_handler_gating.py
avoigt1121
fix(ui): show ADR-0004 denials in the caution box, not only the collapsed JSON
8c4dc99
Raw
History Blame Contribute Delete
7.14 kB
"""Every UI handler and machine endpoint must gate FIRST and fail closed (ADR-0004).
This is the test the sibling repo keeps (`test_ui_handler_gating.py`) and the reason it keeps
it: `api_name=False` hides nothing (ADR-0018), so the per-handler `check_access` call is the
only real boundary. If someone "simplifies" a gate away, this fails.
No network: the gate must deny *before* any tool call, so a denied path never reaches
cBioPortal. The allowed-path tests monkeypatch the tool functions.
"""
from __future__ import annotations
import inspect
import json
import pytest
import gradio_ui
class _Profile:
def __init__(self, username):
self.username = username
@pytest.fixture(autouse=True)
def _enforce(monkeypatch):
"""Turn the gate ON with a one-name allow-list for every test in this module."""
monkeypatch.setenv("ACCESS_CONTROL", "enforce")
monkeypatch.setenv("ALLOWED_IDS", "allowed-user")
monkeypatch.delenv("ADMIN_IDS", raising=False)
monkeypatch.delenv("UPLOAD_ADMIN_IDS", raising=False)
@pytest.fixture(autouse=True)
def _no_sink(monkeypatch, tmp_path):
"""Audit writes go to a temp dir, never the repo's run_logs."""
monkeypatch.setenv("LOG_SINK", "local")
monkeypatch.setenv("LOG_SINK_LOCAL_DIR", str(tmp_path / "run_logs"))
def _explode(*a, **k): # pragma: no cover - must never be reached on a denied path
raise AssertionError("tool was called despite a denied access decision")
def _no_chart(update):
"""True when a plot slot carries no chart — either `None` or a hidden-visibility update.
Handlers no longer agree on how many plot slots they have: variant-status and BYOD render one
chart PER MODALITY (never a stack, since the modalities have different denominators), while
the subtype tab still has one. The gate assertion is "a denied caller sees no chart at all",
which is about the slots' contents, not their count.
"""
if update is None:
return True
return isinstance(update, dict) and update.get("visible") is False
def _final(outputs):
"""The handler's last frame, whether it returns a tuple or yields several.
`_ui_variant_status` is a GENERATOR: it blanks the chart first, then yields the answer, so a
narrowing query can never leave the previous cohort's bars under the new cohort's caution
(prod, 2026-08-05). The gate assertions below are about the ANSWER, so they read the final
frame — and a denied path still yields exactly one, which keeps "denied" indistinguishable
here from a plain return.
"""
if inspect.isgenerator(outputs):
frames = list(outputs)
assert frames, "handler produced no output at all"
return frames[-1]
return outputs
def _assert_denial_visible(caution, reason):
"""A denial must land in the caution box the user can actually see, not only in the
collapsed JSON accordion — the invisible-denial bug (2026-08-14) looked like a dead button."""
assert isinstance(caution, dict)
assert caution.get("visible") is True
assert reason in caution["value"]
UI_CASES = [
("_ui_variant_status", ("cbioportal:paad_tcga", ["KRAS"])),
("_ui_variant_by_subtype", ("cbioportal:paad_tcga", ["KRAS"], "mutation", "")),
("_ui_byod", (None, None, "GRCh38", "human", ["KRAS"])),
]
@pytest.mark.parametrize("handler_name,args", UI_CASES)
def test_ui_handler_denies_anonymous(monkeypatch, handler_name, args):
"""No OAuth profile ⇒ denied, and no tool call happens at all."""
monkeypatch.setattr(gradio_ui, "_query_variant_status", _explode)
monkeypatch.setattr(gradio_ui, "_variant_by_subtype", _explode)
monkeypatch.setattr(gradio_ui, "build_status_matrix", _explode)
outputs = _final(getattr(gradio_ui, handler_name)(*args, profile=None))
assert all(_no_chart(o) for o in outputs[1:-1])
body = json.loads(outputs[-1])
assert body["status"] == "denied"
assert "sign in" in body["reason"].lower()
_assert_denial_visible(outputs[0], body["reason"])
@pytest.mark.parametrize("handler_name,args", UI_CASES)
def test_ui_handler_denies_unlisted_user(monkeypatch, handler_name, args):
"""A signed-in identity that is not on the allow-list is refused by name."""
monkeypatch.setattr(gradio_ui, "_query_variant_status", _explode)
monkeypatch.setattr(gradio_ui, "_variant_by_subtype", _explode)
monkeypatch.setattr(gradio_ui, "build_status_matrix", _explode)
outputs = _final(getattr(gradio_ui, handler_name)(
*args, profile=_Profile("someone-else")
))
assert all(_no_chart(o) for o in outputs[1:-1])
body = json.loads(outputs[-1])
assert body["status"] == "denied"
assert "someone-else" in body["reason"]
_assert_denial_visible(outputs[0], body["reason"])
def test_ui_handler_allows_listed_user(monkeypatch):
"""An allow-listed identity reaches the tool and gets its result back."""
monkeypatch.setattr(
gradio_ui,
"_query_variant_status",
lambda genes, source: {"source": source, "n_samples": 3, "genes": {}},
)
payload = _final(getattr(gradio_ui, "_ui_variant_status")(
"cbioportal:paad_tcga", ["KRAS"], profile=_Profile("allowed-user")
))[-1]
assert json.loads(payload)["n_samples"] == 3
def test_gate_off_by_default(monkeypatch):
"""Dark-launch: with ACCESS_CONTROL unset, an anonymous user is allowed through."""
monkeypatch.delenv("ACCESS_CONTROL", raising=False)
monkeypatch.setattr(
gradio_ui, "_query_variant_status", lambda genes, source: {"n_samples": 1, "genes": {}}
)
payload = _final(gradio_ui._ui_variant_status(
"cbioportal:paad_tcga", ["KRAS"], profile=None
))[-1]
assert json.loads(payload)["n_samples"] == 1
def test_ui_error_is_data_not_exception(monkeypatch):
"""A tool blowing up becomes an error payload, never a crashed handler."""
def _boom(*a, **k):
raise RuntimeError("cBioPortal is down")
monkeypatch.setattr(gradio_ui, "_query_variant_status", _boom)
payload = _final(gradio_ui._ui_variant_status(
"cbioportal:paad_tcga", ["KRAS"], profile=_Profile("allowed-user")
))[-1]
body = json.loads(payload)
assert body["status"] == "error"
assert "cBioPortal is down" in body["reason"]
def test_byod_refusal_surfaces_reason(monkeypatch, tmp_path):
"""A BYOD hard-gate refusal reaches the user as an explanation, not a stack trace."""
from src.workflows.byod import BYODRefusal
def _refuse(*a, **k):
raise BYODRefusal("non_human_species", "Species 'mouse' is not human.")
monkeypatch.setattr(gradio_ui, "build_status_matrix", _refuse)
upload = tmp_path / "variants.maf"
upload.write_text("Hugo_Symbol\tTumor_Sample_Barcode\nKras\tS1\n")
outputs = gradio_ui._ui_byod(
str(upload), None, "GRCh38", "mouse", ["KRAS"], profile=_Profile("allowed-user")
)
caution, payload = outputs[0], outputs[-1]
body = json.loads(payload)
assert body["status"] == "refused"
assert body["reason"] == "non_human_species"
assert "refused" in caution["value"].lower()