Enforce capture limits while draining both subprocess pipes, kill and reap interrupted validators, and bound report allocation before parsing. Preserve result precedence and add synthetic-process regressions with EN/DE documentation. Refs #2
392 lines
13 KiB
Python
392 lines
13 KiB
Python
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("<scenarios/>", encoding="utf-8")
|
|
resources = config / "resources"
|
|
resources.mkdir()
|
|
(resources / "rules.xsl").write_text("<stylesheet/>", 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'<rep:validationStepResult id="step-{index}" valid="{flag}" />'
|
|
for index in range(step_count)
|
|
)
|
|
return (
|
|
f'<rep:report xmlns:rep="http://www.xoev.de/de/validator/varl/1" valid="{flag}" varlVersion="1.0.0">'
|
|
f"<rep:scenarioMatched>{steps}</rep:scenarioMatched>"
|
|
f"<rep:assessment><rep:{assessment}/></rep:assessment>"
|
|
"</rep:report>"
|
|
).encode()
|
|
|
|
|
|
def _invoice() -> InboundInvoice:
|
|
return InboundInvoice(
|
|
tenant_id="tenant-a",
|
|
source_reference="mail:message-1:attachment-1",
|
|
document=b"<Invoice/>",
|
|
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()
|