from __future__ import annotations
from collections.abc import Iterator
from datetime import UTC, datetime
import hashlib
import os
from pathlib import Path
import subprocess
import sys
import time
import pytest
from govoplan_xrechnung.backend import validation
from govoplan_xrechnung.backend.manifest import get_manifest
from govoplan_xrechnung.backend.validation import (
InboundInvoice,
KoSITValidationProfile,
KoSITValidator,
MAX_RUNNER_OUTPUT_BYTES,
XRechnungValidationError,
configuration_tree_sha256,
create_validated_handoff,
interpret_kosit_result,
verify_profile,
)
def _profile(tmp_path: Path, *, minimum_steps: int = 2) -> KoSITValidationProfile:
java = tmp_path / "java"
java.write_bytes(b"#!/bin/sh\n")
java.chmod(0o700)
jar = tmp_path / "validator.jar"
jar.write_bytes(b"pinned validator")
config = tmp_path / "config"
config.mkdir()
scenarios = config / "scenarios.xml"
scenarios.write_text("", encoding="utf-8")
resources = config / "resources"
resources.mkdir()
(resources / "rules.xsl").write_text("", encoding="utf-8")
return KoSITValidationProfile(
profile_id="xrechnung-explicit-test",
xrechnung_version="explicit-test-only",
validator_version="validator-test",
configuration_version="configuration-test",
java_executable=java.resolve(),
validator_jar=jar.resolve(),
validator_jar_sha256=hashlib.sha256(jar.read_bytes()).hexdigest(),
configuration_root=config.resolve(),
configuration_tree_sha256=configuration_tree_sha256(config),
scenarios_file=scenarios.resolve(),
minimum_validation_steps=minimum_steps,
)
def _report(*, valid: bool, step_count: int = 2) -> bytes:
assessment = "accept" if valid else "reject"
flag = "true" if valid else "false"
steps = "".join(
f''
for index in range(step_count)
)
return (
f''
f"{steps}"
f""
""
).encode()
def _invoice() -> InboundInvoice:
return InboundInvoice(
tenant_id="tenant-a",
source_reference="mail:message-1:attachment-1",
document=b"",
received_at=datetime(2026, 8, 23, tzinfo=UTC),
)
def _runner_profile(tmp_path: Path, script: str) -> KoSITValidationProfile:
"""Install a synthetic executable; never invoke Java or a real invoice validator."""
profile = _profile(tmp_path)
profile.java_executable.write_text(
f"#!{sys.executable}\n"
"import os, sys, time\n"
"from pathlib import Path\n"
f"Path(sys.argv[-1]).with_name('invoice-report.xml').write_bytes({_report(valid=True)!r})\n"
+ script,
encoding="utf-8",
)
return profile
@pytest.fixture
def runner_processes(monkeypatch: pytest.MonkeyPatch) -> Iterator[list[subprocess.Popen[bytes]]]:
processes: list[subprocess.Popen[bytes]] = []
real_popen = subprocess.Popen
def start(*args, **kwargs):
process = real_popen(*args, **kwargs)
processes.append(process)
return process
monkeypatch.setattr(validation.subprocess, "Popen", start)
try:
yield processes
finally:
for process in processes:
if process.poll() is None:
process.kill()
process.wait()
def _assert_runner_reaped(processes: list[subprocess.Popen[bytes]]) -> None:
assert len(processes) == 1
process = processes[0]
assert process.returncode is not None
assert process.stdout is not None and process.stdout.closed
assert process.stderr is not None and process.stderr.closed
with pytest.raises(ChildProcessError):
os.waitpid(process.pid, os.WNOHANG)
@pytest.fixture
def report_read_sizes(monkeypatch: pytest.MonkeyPatch) -> list[int]:
sizes: list[int] = []
real_open = Path.open
class ReportReader:
def __init__(self, stream):
self.stream = stream
def __enter__(self):
return self
def __exit__(self, *_args):
self.stream.close()
def read(self, size=-1):
sizes.append(size)
assert size == validation.MAX_REPORT_BYTES + 1
return self.stream.read(size)
def open_file(path, mode="r", *args, **kwargs):
stream = real_open(path, mode, *args, **kwargs)
if path.name == "invoice-report.xml" and mode == "rb":
return ReportReader(stream)
return stream
monkeypatch.setattr(Path, "open", open_file)
return sizes
@pytest.mark.parametrize("stream", ["stdout", "stderr", "both"])
def test_noisy_runner_is_stopped_at_shared_output_limit(
tmp_path: Path,
runner_processes: list[subprocess.Popen[bytes]],
report_read_sizes: list[int],
stream: str,
) -> None:
count = MAX_RUNNER_OUTPUT_BYTES
writes = {
"stdout": f"sys.stdout.buffer.write(b'x' * {count})\nsys.stdout.flush()\n",
"stderr": f"sys.stderr.buffer.write(b'x' * {count})\nsys.stderr.flush()\n",
"both": (
f"sys.stdout.buffer.write(b'x' * {count // 2})\nsys.stdout.flush()\n"
f"sys.stderr.buffer.write(b'x' * {count // 2})\nsys.stderr.flush()\n"
),
}
profile = _runner_profile(tmp_path, writes[stream] + "time.sleep(30)\n")
result = KoSITValidator(profile, timeout_seconds=2).validate(_invoice())
assert result.technical_outcome == "failed"
assert result.technical_reason == "KoSIT runner output exceeded the safety limit."
assert result.conformance == result.assessment == "unknown"
assert not result.handoff_allowed
assert result.report_sha256 is None
assert result.diagnostics == ()
assert report_read_sizes == []
_assert_runner_reaped(runner_processes)
@pytest.mark.parametrize(("extra_bytes", "exit_code"), [(0, 0), (1, 0), (100_000, 0), (100_000, 7)])
def test_report_reads_are_bounded_and_preserve_exact_limit(
tmp_path: Path,
runner_processes: list[subprocess.Popen[bytes]],
report_read_sizes: list[int],
monkeypatch: pytest.MonkeyPatch,
extra_bytes: int,
exit_code: int,
) -> None:
report_limit = len(_report(valid=True))
monkeypatch.setattr(validation, "MAX_REPORT_BYTES", report_limit)
profile = _runner_profile(
tmp_path,
"with Path(sys.argv[-1]).with_name('invoice-report.xml').open('ab') as report:\n"
f" report.write(b' ' * {extra_bytes})\n"
f"sys.exit({exit_code})\n",
)
result = KoSITValidator(profile, timeout_seconds=2).validate(_invoice())
assert report_read_sizes == [report_limit + 1]
if extra_bytes:
assert result.technical_outcome == ("failed" if exit_code else "incomplete")
assert result.technical_reason == (
f"KoSIT runner exited with status {exit_code}; report semantics are not trusted."
if exit_code else "KoSIT did not produce a bounded XML report."
)
assert result.conformance == result.assessment == "unknown"
assert result.report_sha256 is None
assert not result.handoff_allowed
else:
assert result.technical_outcome == "complete"
assert result.report_sha256 == hashlib.sha256(_report(valid=True)).hexdigest()
assert result.handoff_allowed
_assert_runner_reaped(runner_processes)
@pytest.mark.parametrize("excess_bytes", [0, 1])
def test_completed_runner_output_preserves_exact_combined_limit(
tmp_path: Path, runner_processes: list[subprocess.Popen[bytes]], excess_bytes: int
) -> None:
stdout_size = MAX_RUNNER_OUTPUT_BYTES // 2
stderr_size = MAX_RUNNER_OUTPUT_BYTES - stdout_size - 1 + excess_bytes
profile = _runner_profile(
tmp_path,
f"sys.stdout.buffer.write(b'x' * {stdout_size})\n"
f"sys.stderr.buffer.write(b'x' * {stderr_size})\n",
)
result = KoSITValidator(profile, timeout_seconds=2).validate(_invoice())
assert result.technical_outcome == ("failed" if excess_bytes else "complete")
assert result.handoff_allowed is (excess_bytes == 0)
_assert_runner_reaped(runner_processes)
@pytest.mark.parametrize("close_pipes", [False, True])
def test_hanging_runner_times_out_and_is_reaped(
tmp_path: Path, runner_processes: list[subprocess.Popen[bytes]], close_pipes: bool
) -> None:
script = "os.close(1)\nos.close(2)\n" if close_pipes else ""
profile = _runner_profile(tmp_path, script + "time.sleep(30)\n")
started = time.monotonic()
result = KoSITValidator(profile, timeout_seconds=1).validate(_invoice())
assert time.monotonic() - started < 5
assert result.technical_outcome == "failed"
assert result.technical_reason == "KoSIT validation timed out; no handoff is allowed."
assert result.conformance == result.assessment == "unknown"
assert not result.handoff_allowed
_assert_runner_reaped(runner_processes)
@pytest.mark.parametrize(
("script", "outcome", "reason"),
[
("sys.stderr.write('synthetic failure')\nsys.exit(7)\n", "failed", "status 7"),
(
"sys.stdout.buffer.write(b'x' * 65532 + b'\\nERROR synthetic failure')\n",
"incomplete",
"technical error",
),
("sys.stderr.write('ERROR synthetic failure')\n", "incomplete", "technical error"),
],
)
def test_runner_failure_and_technical_output_never_trust_valid_report(
tmp_path: Path,
runner_processes: list[subprocess.Popen[bytes]],
script: str,
outcome: str,
reason: str,
) -> None:
profile = _runner_profile(tmp_path, script)
result = KoSITValidator(profile, timeout_seconds=2).validate(_invoice())
assert result.technical_outcome == outcome
assert reason in result.technical_reason
assert result.conformance == result.assessment == "unknown"
assert not result.handoff_allowed
_assert_runner_reaped(runner_processes)
def test_runner_cancellation_kills_and_reaps_before_propagating(
tmp_path: Path,
runner_processes: list[subprocess.Popen[bytes]],
monkeypatch: pytest.MonkeyPatch,
) -> None:
profile = _runner_profile(tmp_path, "time.sleep(30)\n")
def cancel(_selector, _timeout):
raise KeyboardInterrupt
monkeypatch.setattr(validation.selectors.DefaultSelector, "select", cancel)
with pytest.raises(KeyboardInterrupt):
KoSITValidator(profile, timeout_seconds=2).validate(_invoice())
_assert_runner_reaped(runner_processes)
def test_profile_verifies_exact_engine_and_complete_configuration_tree(tmp_path: Path) -> None:
profile = _profile(tmp_path)
first = verify_profile(profile)
assert len(first) == 64
(profile.configuration_root / "resources" / "rules.xsl").write_text("changed", encoding="utf-8")
with pytest.raises(XRechnungValidationError, match="tree digest"):
verify_profile(profile)
def test_complete_valid_report_can_create_digest_bound_handoff(tmp_path: Path) -> None:
profile = _profile(tmp_path)
invoice = _invoice()
result = interpret_kosit_result(
profile=profile,
profile_sha256=verify_profile(profile),
document_sha256=invoice.document_sha256,
return_code=0,
runner_output=b"INFO validation completed",
report=_report(valid=True),
)
handoff = create_validated_handoff(invoice, result)
assert result.technical_outcome == "complete"
assert result.conformance == "valid"
assert result.assessment == "accept"
assert handoff.document_sha256 == invoice.document_sha256
assert len(handoff.handoff_sha256) == 64
def test_semantically_invalid_report_is_complete_but_cannot_handoff(tmp_path: Path) -> None:
profile = _profile(tmp_path)
invoice = _invoice()
result = interpret_kosit_result(
profile=profile,
profile_sha256=verify_profile(profile),
document_sha256=invoice.document_sha256,
return_code=0,
runner_output=b"INFO validation completed",
report=_report(valid=False),
)
assert result.technical_outcome == "complete"
assert result.conformance == "invalid"
assert result.assessment == "reject"
with pytest.raises(XRechnungValidationError, match="technically complete"):
create_validated_handoff(invoice, result)
@pytest.mark.parametrize(
("return_code", "output", "report", "expected"),
[
(1, b"", _report(valid=True), "failed"),
(0, b"ERROR Transformation failed", _report(valid=True), "incomplete"),
(0, b"", _report(valid=True, step_count=1), "incomplete"),
(0, b"", None, "incomplete"),
],
)
def test_technical_failures_never_trust_a_valid_looking_report(
tmp_path: Path,
return_code: int,
output: bytes,
report: bytes | None,
expected: str,
) -> None:
profile = _profile(tmp_path)
result = interpret_kosit_result(
profile=profile,
profile_sha256=verify_profile(profile),
document_sha256="a" * 64,
return_code=return_code,
runner_output=output,
report=report,
)
assert result.technical_outcome == expected
assert result.conformance == "unknown"
assert result.assessment == "unknown"
assert result.handoff_allowed is False
def test_manifest_does_not_select_an_active_standard_version() -> None:
manifest = get_manifest()
assert manifest.version == "0.1.21"
assert "none is activated by default" in manifest.architecture.known_limits[0].lower()