"""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()