"""Tests for the input-size cap on the TRANSFORM surfaces (OWASP LLM10). The scan surfaces (``scan_lexicon`` / ``scan_output``) cap by *truncating*: they read the prefix, emit an ``oversize-input`` finding, and return findings only. That trade does not transfer here. ``sanitize`` / ``fence`` / ``neutralize`` return **content**, so truncating would either drop user data silently or hand back a tail that never went through the transform — a one-line bypass, since an attacker controls where in the document the payload sits. So these three fail *secure* instead. The invariant under test: returned text is ALWAYS fully transformed, or not returned at all. """ import inspect import pytest from llm_ingestion_guard import prepare_input from llm_ingestion_guard.calibration import MAX_INPUT_CHARS from llm_ingestion_guard.contract import ContractViolation, OversizeInputError from llm_ingestion_guard.fence import fence from llm_ingestion_guard.neutralize import neutralize from llm_ingestion_guard.sanitize import sanitize # Every transform surface, called through a uniform (text, max) shim so the # invariant is asserted once per surface rather than restated three times. TRANSFORMS = ( ("sanitize", lambda text, cap: sanitize(text, max_input_chars=cap)), ("fence", lambda text, cap: fence(text, max_input_chars=cap)), ("neutralize", lambda text, cap: neutralize(text, max_input_chars=cap)), ) @pytest.mark.parametrize("name,call", TRANSFORMS, ids=[n for n, _ in TRANSFORMS]) def test_oversize_input_is_rejected(name, call): with pytest.raises(OversizeInputError): call("a" * 101, 100) @pytest.mark.parametrize("name,call", TRANSFORMS, ids=[n for n, _ in TRANSFORMS]) def test_input_exactly_at_the_cap_is_accepted(name, call): # The cap is the largest accepted size, not the smallest rejected one. result = call("a" * 100, 100) assert result.text is not None @pytest.mark.parametrize("name,call", TRANSFORMS, ids=[n for n, _ in TRANSFORMS]) def test_rejection_is_a_contract_violation(name, call): # Subclass, so a pipeline with a broad `except ContractViolation` around its # quarantined stage keeps failing closed instead of meeting a new exception # type it has never heard of. with pytest.raises(ContractViolation) as exc: call("a" * 101, 100) assert exc.value.code == "oversize-input" @pytest.mark.parametrize("name,call", TRANSFORMS, ids=[n for n, _ in TRANSFORMS]) def test_error_carries_sizes_never_content(name, call): # Same alert-safety property ContractViolation already promises: the raised # object must be routable to an alert channel without leaking the payload. secret = "CANARYVALUE" with pytest.raises(OversizeInputError) as exc: call(secret * 100, 100) assert secret not in str(exc.value) assert not any(secret in d for d in exc.value.details) # Substring, not tuple membership: `details` is ("sanitize",), so `"101" not # in details` would pass trivially and keep passing if a size were ever # folded into the string. assert not any("101" in d for d in exc.value.details) assert name in exc.value.details @pytest.mark.parametrize("name,call", TRANSFORMS, ids=[n for n, _ in TRANSFORMS]) def test_error_reports_both_the_size_and_the_cap(name, call): with pytest.raises(OversizeInputError) as exc: call("a" * 101, 100) message = str(exc.value) assert "101" in message and "100" in message @pytest.mark.parametrize("name,call", TRANSFORMS, ids=[n for n, _ in TRANSFORMS]) def test_under_cap_is_unchanged_by_the_guard(name, call): # The cap must not perturb the ordinary path: clean prose still round-trips # the way each surface's own tests already pin. result = call("ordinary prose", 100) assert "ordinary prose" in result.text @pytest.mark.parametrize( "func", [sanitize, fence, neutralize], ids=["sanitize", "fence", "neutralize"] ) def test_default_cap_comes_from_calibration(func): # Calibration numbers live in ONE module; a literal re-typed at a call site # is the drift this pins against. default = inspect.signature(func).parameters["max_input_chars"].default assert default == MAX_INPUT_CHARS def test_prepare_input_inherits_the_cap(): # The input path is sanitize -> fence, and step 1 is where the document # arrives, so the whole path is bounded by sanitize's guard alone. with pytest.raises(OversizeInputError): prepare_input("a" * (MAX_INPUT_CHARS + 1)) def test_prepare_input_accepts_a_document_at_the_cap(): result = prepare_input("a" * MAX_INPUT_CHARS) assert result.fenced