Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b3ccc56eff | ||
|
|
94bad1dbad | ||
|
|
ac43ecccaf |
@@ -0,0 +1,270 @@
|
|||||||
|
name: Module Package Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
release_tag:
|
||||||
|
description: Existing protected version tag to publish
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish-packages:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
GITEA_REPOSITORY: ${{ gitea.repository }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
|
||||||
|
with:
|
||||||
|
node-version: "22"
|
||||||
|
- name: Select and validate protected release tag
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
REQUESTED_TAG: ${{ inputs.release_tag }}
|
||||||
|
TRIGGER_TAG: ${{ gitea.ref_name }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
tag="${REQUESTED_TAG:-$TRIGGER_TAG}"
|
||||||
|
case "$tag" in
|
||||||
|
v[0-9]*.[0-9]*.[0-9]*) ;;
|
||||||
|
*) echo "Release tag must start with a SemVer-shaped vX.Y.Z value" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
git fetch --force origin "refs/tags/$tag:refs/tags/$tag" refs/heads/main:refs/remotes/origin/main
|
||||||
|
tag_commit="$(git rev-list -n 1 "$tag")"
|
||||||
|
git merge-base --is-ancestor "$tag_commit" refs/remotes/origin/main || {
|
||||||
|
echo "Release tag is not contained in main" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
git checkout --detach "$tag"
|
||||||
|
printf 'RELEASE_TAG=%s\n' "$tag" >> "$GITEA_ENV"
|
||||||
|
printf 'SOURCE_DATE_EPOCH=%s\n' "$(git show -s --format=%ct HEAD)" >> "$GITEA_ENV"
|
||||||
|
- name: Validate package versions
|
||||||
|
run: |
|
||||||
|
python - <<'PY'
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import tomllib
|
||||||
|
|
||||||
|
tag = os.environ["RELEASE_TAG"]
|
||||||
|
expected = tag.removeprefix("v")
|
||||||
|
project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"]
|
||||||
|
if project.get("version") != expected:
|
||||||
|
raise SystemExit(f"pyproject version {project.get('version')!r} does not match {tag}")
|
||||||
|
if re.fullmatch(r"govoplan-[a-z0-9-]+", str(project.get("name", ""))) is None:
|
||||||
|
raise SystemExit("Python distribution name must use the govoplan-* namespace")
|
||||||
|
webui = Path("webui/package.json")
|
||||||
|
if webui.is_file():
|
||||||
|
package = json.loads(webui.read_text(encoding="utf-8"))
|
||||||
|
if package.get("version") != expected:
|
||||||
|
raise SystemExit(f"WebUI version {package.get('version')!r} does not match {tag}")
|
||||||
|
if re.fullmatch(r"@govoplan/[a-z0-9-]+-webui", str(package.get("name", ""))) is None:
|
||||||
|
raise SystemExit("WebUI package name must use the @govoplan/*-webui namespace")
|
||||||
|
release = Path("webui/package.release.json")
|
||||||
|
if release.is_file():
|
||||||
|
release_package = json.loads(release.read_text(encoding="utf-8"))
|
||||||
|
if (
|
||||||
|
release_package.get("name") != package.get("name")
|
||||||
|
or release_package.get("version") != expected
|
||||||
|
):
|
||||||
|
raise SystemExit("WebUI release package identity does not match package.json and the release tag")
|
||||||
|
PY
|
||||||
|
- name: Build immutable package artifacts
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
python -m pip install --disable-pip-version-check build==1.5.0 twine==7.0.0
|
||||||
|
rm -rf dist .package-webui
|
||||||
|
python -m build --wheel --outdir dist
|
||||||
|
python -m twine check dist/*.whl
|
||||||
|
if [[ -f webui/package.json ]]; then
|
||||||
|
mkdir .package-webui
|
||||||
|
cp -a webui/. .package-webui/
|
||||||
|
rm -rf .package-webui/node_modules .package-webui/dist
|
||||||
|
if [[ -f .package-webui/package.release.json ]]; then
|
||||||
|
cp .package-webui/package.release.json .package-webui/package.json
|
||||||
|
fi
|
||||||
|
node <<'NODE'
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const path = ".package-webui/package.json";
|
||||||
|
const packageJson = JSON.parse(fs.readFileSync(path, "utf8"));
|
||||||
|
const groups = ["dependencies", "optionalDependencies", "peerDependencies"];
|
||||||
|
for (const group of groups) {
|
||||||
|
for (const [name, specifier] of Object.entries(packageJson[group] || {})) {
|
||||||
|
if (!name.startsWith("@govoplan/")) continue;
|
||||||
|
if (typeof specifier !== "string") {
|
||||||
|
throw new Error(`${group}.${name} must use a string version`);
|
||||||
|
}
|
||||||
|
const packageSlug = name.slice("@govoplan/".length);
|
||||||
|
if (!packageSlug.endsWith("-webui")) {
|
||||||
|
throw new Error(`${group}.${name} is outside the WebUI package namespace`);
|
||||||
|
}
|
||||||
|
const repository = `govoplan-${packageSlug.slice(0, -"-webui".length)}`;
|
||||||
|
const escapedRepository = repository.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
const gitTag = specifier.match(
|
||||||
|
new RegExp(
|
||||||
|
`^git\\+(?:ssh://git@|https://)git\\.add-ideas\\.de/(?:GovOPlaN|add-ideas)/${escapedRepository}\\.git#v([0-9]+\\.[0-9]+\\.[0-9]+)$`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (gitTag) {
|
||||||
|
packageJson[group][name] = gitTag[1];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (specifier.startsWith("file:") || specifier.startsWith("git+")) {
|
||||||
|
throw new Error(
|
||||||
|
`${group}.${name} must resolve to an exact registry version for publication`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delete packageJson.private;
|
||||||
|
fs.writeFileSync(path, `${JSON.stringify(packageJson, null, 2)}\n`);
|
||||||
|
NODE
|
||||||
|
npm pkg delete private --prefix .package-webui
|
||||||
|
(cd .package-webui && npm pack --ignore-scripts --pack-destination ../dist)
|
||||||
|
fi
|
||||||
|
python - <<'PY'
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
artifacts = []
|
||||||
|
for path in sorted(Path("dist").iterdir()):
|
||||||
|
if path.suffix not in {".whl", ".tgz"}:
|
||||||
|
continue
|
||||||
|
digest = hashlib.sha256(path.read_bytes()).hexdigest()
|
||||||
|
artifacts.append({"filename": path.name, "sha256": digest, "size": path.stat().st_size})
|
||||||
|
payload = {
|
||||||
|
"schema_version": "1",
|
||||||
|
"repository": os.environ["GITEA_REPOSITORY"],
|
||||||
|
"tag": os.environ["RELEASE_TAG"],
|
||||||
|
"commit": subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip(),
|
||||||
|
"artifacts": artifacts,
|
||||||
|
}
|
||||||
|
Path("dist/package-artifacts.json").write_text(
|
||||||
|
json.dumps(payload, indent=2, sort_keys=True) + "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
PY
|
||||||
|
- name: Retain package hash evidence
|
||||||
|
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32
|
||||||
|
with:
|
||||||
|
name: module-packages-${{ gitea.ref_name }}
|
||||||
|
path: dist/package-artifacts.json
|
||||||
|
- name: Check immutable registry state
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
PACKAGE_TOKEN: ${{ secrets.GOVOPLAN_PACKAGE_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
test -n "$PACKAGE_TOKEN"
|
||||||
|
python - <<'PY'
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import tomllib
|
||||||
|
from urllib.error import HTTPError
|
||||||
|
from urllib.parse import quote
|
||||||
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
|
api_root = "https://git.add-ideas.de/api/v1/packages/GovOPlaN"
|
||||||
|
token = os.environ["PACKAGE_TOKEN"]
|
||||||
|
|
||||||
|
def should_publish(kind, name, version, path):
|
||||||
|
package_url = "/".join(
|
||||||
|
(api_root, kind, quote(name, safe=""), quote(version, safe=""), "files")
|
||||||
|
)
|
||||||
|
request = Request(
|
||||||
|
package_url,
|
||||||
|
headers={"Accept": "application/json", "Authorization": f"token {token}"},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urlopen(request, timeout=30) as response:
|
||||||
|
files = json.load(response)
|
||||||
|
except HTTPError as exc:
|
||||||
|
if exc.code == 404:
|
||||||
|
print(f"{kind} package {name}=={version} is not published yet")
|
||||||
|
return True
|
||||||
|
raise
|
||||||
|
if not isinstance(files, list) or len(files) != 1:
|
||||||
|
raise SystemExit(
|
||||||
|
f"immutable {kind} package {name}=={version} has an unexpected file set"
|
||||||
|
)
|
||||||
|
expected_sha256 = hashlib.sha256(path.read_bytes()).hexdigest()
|
||||||
|
if files[0].get("sha256") != expected_sha256:
|
||||||
|
raise SystemExit(
|
||||||
|
f"immutable {kind} package {name}=={version} already exists with a different SHA-256"
|
||||||
|
)
|
||||||
|
print(f"verified existing {kind} package {name}=={version} ({expected_sha256})")
|
||||||
|
return False
|
||||||
|
|
||||||
|
project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"]
|
||||||
|
wheels = tuple(Path("dist").glob("*.whl"))
|
||||||
|
if len(wheels) != 1:
|
||||||
|
raise SystemExit("release build must contain exactly one wheel")
|
||||||
|
publish_pypi = should_publish(
|
||||||
|
"pypi", str(project["name"]), str(project["version"]), wheels[0]
|
||||||
|
)
|
||||||
|
|
||||||
|
tarballs = tuple(Path("dist").glob("*.tgz"))
|
||||||
|
if len(tarballs) > 1:
|
||||||
|
raise SystemExit("release build must contain at most one npm package")
|
||||||
|
publish_npm = False
|
||||||
|
if tarballs:
|
||||||
|
webui = json.loads(
|
||||||
|
Path(".package-webui/package.json").read_text(encoding="utf-8")
|
||||||
|
)
|
||||||
|
publish_npm = should_publish(
|
||||||
|
"npm", str(webui["name"]), str(webui["version"]), tarballs[0]
|
||||||
|
)
|
||||||
|
|
||||||
|
with Path(os.environ["GITEA_ENV"]).open("a", encoding="utf-8") as env_file:
|
||||||
|
env_file.write(f"PUBLISH_PYPI={int(publish_pypi)}\n")
|
||||||
|
env_file.write(f"PUBLISH_NPM={int(publish_npm)}\n")
|
||||||
|
PY
|
||||||
|
- name: Publish wheel and WebUI package
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
PACKAGE_USERNAME: ${{ secrets.GOVOPLAN_PACKAGE_USERNAME }}
|
||||||
|
PACKAGE_TOKEN: ${{ secrets.GOVOPLAN_PACKAGE_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
test -n "$PACKAGE_USERNAME"
|
||||||
|
test -n "$PACKAGE_TOKEN"
|
||||||
|
if [[ "$PUBLISH_PYPI" == 1 ]]; then
|
||||||
|
TWINE_USERNAME="$PACKAGE_USERNAME" TWINE_PASSWORD="$PACKAGE_TOKEN" \
|
||||||
|
python -m twine upload --non-interactive \
|
||||||
|
--repository-url https://git.add-ideas.de/api/packages/GovOPlaN/pypi \
|
||||||
|
dist/*.whl
|
||||||
|
else
|
||||||
|
echo "Exact wheel is already present; skipping immutable retry."
|
||||||
|
fi
|
||||||
|
shopt -s nullglob
|
||||||
|
webui_packages=(dist/*.tgz)
|
||||||
|
if (( ${#webui_packages[@]} )) && [[ "$PUBLISH_NPM" == 1 ]]; then
|
||||||
|
npmrc="$(mktemp)"
|
||||||
|
trap 'rm -f "$npmrc"' EXIT
|
||||||
|
chmod 600 "$npmrc"
|
||||||
|
printf '%s\n' \
|
||||||
|
'@govoplan:registry=https://git.add-ideas.de/api/packages/GovOPlaN/npm/' \
|
||||||
|
"//git.add-ideas.de/api/packages/GovOPlaN/npm/:_authToken=$PACKAGE_TOKEN" \
|
||||||
|
> "$npmrc"
|
||||||
|
NPM_CONFIG_USERCONFIG="$npmrc" npm publish "./${webui_packages[0]}" \
|
||||||
|
--ignore-scripts --access public \
|
||||||
|
--registry https://git.add-ideas.de/api/packages/GovOPlaN/npm/
|
||||||
|
elif (( ${#webui_packages[@]} )); then
|
||||||
|
echo "Exact WebUI package is already present; skipping immutable retry."
|
||||||
|
fi
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# govoplan-xrechnung
|
||||||
|
|
||||||
|
Optional connector for fail-closed inbound XRechnung validation and digest-bound handoff. A deployment must explicitly select and pin the exact XRechnung and KoSIT validator configuration; no standard version is activated by default.
|
||||||
|
|
||||||
|
See [the inbound validation profile](docs/INBOUND_VALIDATION.md).
|
||||||
|
|
||||||
|
<!-- govoplan-repository-type:start -->
|
||||||
|
**Repository type:** connector (standard).
|
||||||
|
<!-- govoplan-repository-type:end -->
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# Inbound XRechnung validation
|
||||||
|
|
||||||
|
This module validates an inbound XML invoice through a locally installed, exact KoSIT validator profile. It does not choose the active standard release, download validation artifacts at runtime, approve a payable, or book an invoice.
|
||||||
|
|
||||||
|
## Pinned profile
|
||||||
|
|
||||||
|
An administrator records the XRechnung version, validator version, configuration release, absolute Java and JAR paths, JAR SHA-256, configuration root and complete deterministic tree SHA-256, scenario file, and expected minimum validation-step count. Every run rechecks the executable and both artifact digests. Symbolic links and files outside the configuration root are rejected.
|
||||||
|
|
||||||
|
## Deployment-owned profile allow-list
|
||||||
|
|
||||||
|
`KoSITProfileRegistry` allows a deployment to approve several exact profiles
|
||||||
|
without GovOPlaN choosing a standard version. Each approval binds the verified
|
||||||
|
artifact digest, approving actor, decision reference, receive-time window, and
|
||||||
|
the state `approved`, `suspended`, or `retired`. A deployment may configure one
|
||||||
|
explicit default, or require the invoking workflow to select a profile every
|
||||||
|
time. A default is never inferred.
|
||||||
|
|
||||||
|
Selection uses the invoice receive time, which permits an administrator to
|
||||||
|
model an overlap or transition window. Suspended, retired, unknown, expired,
|
||||||
|
not-yet-valid, or subsequently modified profiles fail closed. Artifact digests
|
||||||
|
are rechecked on selection and again during validation.
|
||||||
|
|
||||||
|
Administrierende können damit mehrere konkrete Profile zeitlich begrenzt
|
||||||
|
freigeben, ohne dass GovOPlaN eine XRechnungs-Version vorgibt. Übergangsfristen
|
||||||
|
und ein Standardprofil sind ausdrücklich konfigurierte Entscheidungen. Ohne
|
||||||
|
Standard muss jeder aufrufende Prozess ein freigegebenes Profil benennen.
|
||||||
|
|
||||||
|
The configuration tree must be installed through deployment governance. A newer published release never becomes active automatically. Updating any artifact creates a different profile digest and requires regression evidence with accepted, rejected, malformed, and deliberately broken technical fixtures.
|
||||||
|
|
||||||
|
## Fail-closed validation
|
||||||
|
|
||||||
|
Inbound XML is size-bounded and parsed with external entities and DTD processing disabled before Java runs. KoSIT runs without a shell, with an absolute executable, fixed argument vector, bounded time, bounded captured output, and an isolated result directory.
|
||||||
|
|
||||||
|
GovOPlaN does not trust the report alone. A technically complete result requires a zero process status, no technical error marker in runner output, a bounded well-formed VARL report, a matched scenario, at least the configured number of complete validation steps, and exactly one assessment. Formal validity and the accept/reject recommendation are retained separately because warnings can make them differ. This compensates for the known risk that a partial report can look valid after a transformation failure.
|
||||||
|
|
||||||
|
Semantic invalidity is different from technical failure. A complete reject report is `invalid`; a timeout, crash, partial report, or inconsistent assessment is `unknown`. Neither result may be handed off as valid.
|
||||||
|
|
||||||
|
## Governed handoff
|
||||||
|
|
||||||
|
Only a technically complete, formally `valid`, and explicitly accepted result creates a handoff. The handoff binds tenant, source reference, invoice SHA-256, VARL report SHA-256, profile ID and profile SHA-256, and validation time. Procurement or Payments owns the later payable workflow; Files owns retained invoice bytes; Records may file an exact revision. Revalidation is mandatory after invoice or profile changes.
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=69", "wheel"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "govoplan-xrechnung"
|
||||||
|
version = "0.1.20"
|
||||||
|
description = "GovOPlaN inbound XRechnung validation connector."
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
authors = [{ name = "GovOPlaN" }]
|
||||||
|
dependencies = [
|
||||||
|
"defusedxml>=0.7.1",
|
||||||
|
"govoplan-core>=0.1.37",
|
||||||
|
"govoplan-access>=0.1.18",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
where = ["src"]
|
||||||
|
|
||||||
|
[tool.setuptools.package-data]
|
||||||
|
govoplan_xrechnung = ["py.typed"]
|
||||||
|
|
||||||
|
[project.entry-points."govoplan.modules"]
|
||||||
|
xrechnung = "govoplan_xrechnung.backend.manifest:get_manifest"
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
pythonpath = ["src"]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 100
|
||||||
|
target-version = "py312"
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
"""GovOPlaN XRechnung connector."""
|
||||||
|
|
||||||
|
__version__ = "0.1.19"
|
||||||
|
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
"""Inbound XRechnung validation boundary."""
|
||||||
|
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from govoplan_core.core.access import (
|
||||||
|
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||||
|
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.modules import (
|
||||||
|
DocumentationCondition,
|
||||||
|
DocumentationLink,
|
||||||
|
DocumentationTopic,
|
||||||
|
ModuleManifest,
|
||||||
|
PermissionDefinition,
|
||||||
|
RoleTemplate,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.provider_governance import declared_module_architecture
|
||||||
|
|
||||||
|
|
||||||
|
MODULE_ID = "xrechnung"
|
||||||
|
MODULE_VERSION = "0.1.20"
|
||||||
|
READ_SCOPE = "xrechnung:validation:read"
|
||||||
|
EXECUTE_SCOPE = "xrechnung:validation:execute"
|
||||||
|
HANDOFF_SCOPE = "xrechnung:handoff:create"
|
||||||
|
|
||||||
|
|
||||||
|
def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
|
||||||
|
module_id, resource, action = scope.split(":", 2)
|
||||||
|
return PermissionDefinition(
|
||||||
|
scope=scope,
|
||||||
|
label=label,
|
||||||
|
description=description,
|
||||||
|
category="XRechnung",
|
||||||
|
level="tenant",
|
||||||
|
module_id=module_id,
|
||||||
|
resource=resource,
|
||||||
|
action=action,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
manifest = ModuleManifest(
|
||||||
|
id=MODULE_ID,
|
||||||
|
name="XRechnung",
|
||||||
|
version=MODULE_VERSION,
|
||||||
|
dependencies=("access",),
|
||||||
|
optional_dependencies=("files", "procurement", "payments", "records", "audit"),
|
||||||
|
required_capabilities=(
|
||||||
|
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||||
|
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||||
|
),
|
||||||
|
permissions=(
|
||||||
|
_permission(READ_SCOPE, "View XRechnung validation", "Read validation profiles, outcomes, and non-secret diagnostics."),
|
||||||
|
_permission(EXECUTE_SCOPE, "Validate inbound XRechnung", "Run a pinned local KoSIT profile against an inbound invoice."),
|
||||||
|
_permission(HANDOFF_SCOPE, "Handoff validated XRechnung", "Create a digest-bound handoff only from technically complete valid evidence."),
|
||||||
|
),
|
||||||
|
role_templates=(
|
||||||
|
RoleTemplate(
|
||||||
|
slug="xrechnung_processor",
|
||||||
|
name="XRechnung processor",
|
||||||
|
description="Validate inbound invoices and create governed valid-invoice handoffs.",
|
||||||
|
permissions=(READ_SCOPE, EXECUTE_SCOPE, HANDOFF_SCOPE),
|
||||||
|
),
|
||||||
|
RoleTemplate(
|
||||||
|
slug="xrechnung_reviewer",
|
||||||
|
name="XRechnung reviewer",
|
||||||
|
description="Inspect validation profiles, results, and diagnostics without creating effects.",
|
||||||
|
permissions=(READ_SCOPE,),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
documentation=(
|
||||||
|
DocumentationTopic(
|
||||||
|
id="xrechnung.inbound-validation",
|
||||||
|
title="Validate and hand off an inbound XRechnung",
|
||||||
|
summary="Verify safe invoice XML with an exact locally pinned KoSIT engine and rule tree, then create a digest-bound handoff only from complete valid evidence.",
|
||||||
|
body=(
|
||||||
|
"An administrator must approve an exact XRechnung version, KoSIT validator release, configuration release, configuration-tree digest, engine digest, scenario file, and minimum validation-step count. A deployment-owned allow-list records approval identity, decision reference, receive-time window, suspension or retirement state, and an optional explicit default; GovOPlaN selects no version itself. The connector verifies those artifacts before each run and never downloads rules while processing an invoice. It rejects dangerous XML before invoking Java. Runner exit, bounded output, technical error markers, VARL identity, matched scenario, configured validation-step count, validity flag, and exactly one accept or reject assessment are checked independently. Technical failure or partial reports yield unknown conformance and can never be handed off. A valid handoff binds tenant, source reference, invoice digest, report digest, and profile digest for an owning Procurement or Payments workflow."
|
||||||
|
),
|
||||||
|
layer="configured",
|
||||||
|
documentation_types=("admin", "user"),
|
||||||
|
audience=("user", "operator", "module_admin", "auditor"),
|
||||||
|
related_modules=("files", "procurement", "payments", "records", "audit"),
|
||||||
|
conditions=(
|
||||||
|
DocumentationCondition(
|
||||||
|
any_scopes=(READ_SCOPE, EXECUTE_SCOPE, HANDOFF_SCOPE),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
links=(
|
||||||
|
DocumentationLink(
|
||||||
|
label="Inbound validation profile",
|
||||||
|
href="docs/INBOUND_VALIDATION.md",
|
||||||
|
kind="repository",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Eingehende XRechnung prüfen und übergeben",
|
||||||
|
"summary": "Sicheres Rechnungs-XML mit exakt lokal festgelegter KoSIT-Engine und Regelstruktur prüfen und nur aus vollständigem gültigem Nachweis eine prüfsummengebundene Übergabe erzeugen.",
|
||||||
|
"body": "Administrierende müssen eine exakte XRechnungs-Version, KoSIT-Validator-Version, Konfigurationsversion, Prüfsumme der Konfigurationsstruktur, Engine-Prüfsumme, Szenariodatei und Mindestzahl der Prüfschritte freigeben. Eine installationsbezogene Positivliste erfasst Freigabestelle, Entscheidungsreferenz, Eingangszeitfenster, Sperrung oder Ausmusterung und einen optional ausdrücklich gesetzten Standard; GovOPlaN wählt selbst keine Version. Der Konnektor prüft die Artefakte vor jedem Lauf und lädt während der Rechnungsverarbeitung keine Regeln herunter. Gefährliches XML wird vor dem Java-Aufruf abgewiesen. Prozessende, begrenzte Ausgabe, technische Fehlermarker, VARL-Identität, erkanntes Szenario, konfigurierte Prüfschrittzahl, Gültigkeitskennzeichen und genau eine Annahme- oder Ablehnungsempfehlung werden unabhängig geprüft. Technische Fehler oder Teilberichte ergeben unbekannte Konformität und dürfen niemals übergeben werden. Eine gültige Übergabe bindet Mandant, Quellreferenz, Rechnungsprüfsumme, Berichtsprüfsumme und Profilprüfsumme für einen fachlich verantwortlichen Procurement- oder Payments-Ablauf.",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
metadata={
|
||||||
|
"kind": "workflow",
|
||||||
|
"prerequisites": [
|
||||||
|
"An exact KoSIT engine and self-contained XRechnung configuration have been installed locally.",
|
||||||
|
"The administrator has approved all artifact digests and the expected validation-step count.",
|
||||||
|
"The profile is approved for the invoice receive time in the deployment allow-list.",
|
||||||
|
"The owning invoice workflow supplies a stable tenant and source reference.",
|
||||||
|
],
|
||||||
|
"steps": [
|
||||||
|
"Verify the executable, engine digest, complete configuration-tree digest, and scenario location.",
|
||||||
|
"Select an explicit allow-listed profile or the deployment's explicitly configured default.",
|
||||||
|
"Reject unsafe or oversized XML, then run the pinned profile with bounded time and output.",
|
||||||
|
"Check technical completeness separately from the report's semantic validity.",
|
||||||
|
"Create a handoff only when the report is complete, formally valid, and accepts the invoice.",
|
||||||
|
],
|
||||||
|
"limitations": [
|
||||||
|
"No XRechnung/configuration version is activated by default in this release.",
|
||||||
|
"The connector creates a handoff contract but does not own payable approval or booking.",
|
||||||
|
"A syntactically valid report is not trusted when runner output signals a technical failure.",
|
||||||
|
],
|
||||||
|
"consequences": [
|
||||||
|
"Changing any pinned artifact changes the profile digest and requires a new validation.",
|
||||||
|
"Invalid invoices retain diagnostics but cannot enter the valid-invoice handoff.",
|
||||||
|
"Technical failure produces unknown conformance, never a semantic rejection or acceptance.",
|
||||||
|
],
|
||||||
|
"verification": "Confirm artifact and profile digests, technical outcome, validation-step count, formal validity, VARL assessment, invoice digest, and report digest before following the handoff reference.",
|
||||||
|
},
|
||||||
|
order=100,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
architecture=declared_module_architecture(
|
||||||
|
layer="data_reporting_integration",
|
||||||
|
kind="integration",
|
||||||
|
maturity="vertical_slice",
|
||||||
|
documentation_ref="docs/INBOUND_VALIDATION.md",
|
||||||
|
test_ref="tests/test_validation.py",
|
||||||
|
known_limits=("A deployment-approved XRechnung and KoSIT configuration profile is required; none is activated by default.",),
|
||||||
|
owned_concepts=("XRechnung validation profile", "validation result", "validated invoice handoff"),
|
||||||
|
non_owned_concepts=("invoice payable", "procurement approval", "booking status", "invoice file storage"),
|
||||||
|
recovery_docs=("docs/INBOUND_VALIDATION.md",),
|
||||||
|
security_docs=("docs/INBOUND_VALIDATION.md",),
|
||||||
|
operations_docs=("docs/INBOUND_VALIDATION.md",),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_manifest() -> ModuleManifest:
|
||||||
|
return manifest
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from govoplan_xrechnung.backend.validation import (
|
||||||
|
KoSITValidationProfile,
|
||||||
|
XRechnungValidationError,
|
||||||
|
verify_profile,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
ProfileApprovalStatus = Literal["approved", "suspended", "retired"]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class KoSITProfileApproval:
|
||||||
|
"""A reviewed deployment decision bound to exact installed artifacts."""
|
||||||
|
|
||||||
|
profile: KoSITValidationProfile
|
||||||
|
profile_sha256: str
|
||||||
|
status: ProfileApprovalStatus
|
||||||
|
approved_at: datetime
|
||||||
|
approved_by: str
|
||||||
|
decision_reference: str
|
||||||
|
accept_from: datetime
|
||||||
|
accept_until: datetime | None = None
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if self.status not in {"approved", "suspended", "retired"}:
|
||||||
|
raise ValueError("XRechnung profile approval status is unsupported.")
|
||||||
|
for name in ("approved_by", "decision_reference"):
|
||||||
|
value = str(getattr(self, name) or "").strip()
|
||||||
|
if not value or len(value) > 255 or any(ord(char) < 32 for char in value):
|
||||||
|
raise ValueError(
|
||||||
|
f"XRechnung {name.replace('_', ' ')} is required and bounded."
|
||||||
|
)
|
||||||
|
object.__setattr__(self, name, value)
|
||||||
|
for name in ("approved_at", "accept_from"):
|
||||||
|
_aware(getattr(self, name), name)
|
||||||
|
if self.accept_until is not None:
|
||||||
|
_aware(self.accept_until, "accept_until")
|
||||||
|
if self.accept_until <= self.accept_from:
|
||||||
|
raise ValueError("XRechnung accept_until must be later than accept_from.")
|
||||||
|
normalized_digest = str(self.profile_sha256 or "").strip().lower()
|
||||||
|
if len(normalized_digest) != 64 or any(
|
||||||
|
character not in "0123456789abcdef" for character in normalized_digest
|
||||||
|
):
|
||||||
|
raise ValueError("XRechnung approved profile digest must be SHA-256.")
|
||||||
|
object.__setattr__(self, "profile_sha256", normalized_digest)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def approve(
|
||||||
|
cls,
|
||||||
|
profile: KoSITValidationProfile,
|
||||||
|
*,
|
||||||
|
approved_at: datetime,
|
||||||
|
approved_by: str,
|
||||||
|
decision_reference: str,
|
||||||
|
accept_from: datetime,
|
||||||
|
accept_until: datetime | None = None,
|
||||||
|
) -> KoSITProfileApproval:
|
||||||
|
return cls(
|
||||||
|
profile=profile,
|
||||||
|
profile_sha256=verify_profile(profile),
|
||||||
|
status="approved",
|
||||||
|
approved_at=approved_at,
|
||||||
|
approved_by=approved_by,
|
||||||
|
decision_reference=decision_reference,
|
||||||
|
accept_from=accept_from,
|
||||||
|
accept_until=accept_until,
|
||||||
|
)
|
||||||
|
|
||||||
|
def accepts(self, received_at: datetime) -> bool:
|
||||||
|
_aware(received_at, "invoice received_at")
|
||||||
|
return (
|
||||||
|
self.status == "approved"
|
||||||
|
and received_at >= self.accept_from
|
||||||
|
and (self.accept_until is None or received_at < self.accept_until)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class KoSITProfileRegistry:
|
||||||
|
"""Deployment-owned allow-list with an optional, explicit default profile."""
|
||||||
|
|
||||||
|
approvals: tuple[KoSITProfileApproval, ...]
|
||||||
|
default_profile_id: str | None = None
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if not self.approvals:
|
||||||
|
raise ValueError("XRechnung profile registry requires at least one reviewed entry.")
|
||||||
|
profile_ids = [item.profile.profile_id for item in self.approvals]
|
||||||
|
if len(profile_ids) != len(set(profile_ids)):
|
||||||
|
raise ValueError("XRechnung profile registry contains duplicate profile ids.")
|
||||||
|
profile_digests = [item.profile_sha256 for item in self.approvals]
|
||||||
|
if len(profile_digests) != len(set(profile_digests)):
|
||||||
|
raise ValueError("XRechnung profile registry contains duplicate artifact profiles.")
|
||||||
|
if self.default_profile_id is not None:
|
||||||
|
normalized = str(self.default_profile_id or "").strip()
|
||||||
|
if normalized not in profile_ids:
|
||||||
|
raise ValueError("XRechnung default profile is not present in the allow-list.")
|
||||||
|
object.__setattr__(self, "default_profile_id", normalized)
|
||||||
|
|
||||||
|
def select(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
received_at: datetime,
|
||||||
|
profile_id: str | None = None,
|
||||||
|
) -> KoSITValidationProfile:
|
||||||
|
selected_id = str(profile_id or self.default_profile_id or "").strip()
|
||||||
|
if not selected_id:
|
||||||
|
raise XRechnungValidationError(
|
||||||
|
"No XRechnung profile was selected and the deployment has no default."
|
||||||
|
)
|
||||||
|
approval = next(
|
||||||
|
(item for item in self.approvals if item.profile.profile_id == selected_id),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if approval is None:
|
||||||
|
raise XRechnungValidationError(
|
||||||
|
"The selected XRechnung profile is not in the deployment allow-list."
|
||||||
|
)
|
||||||
|
if not approval.accepts(received_at):
|
||||||
|
raise XRechnungValidationError(
|
||||||
|
"The selected XRechnung profile is not approved for the invoice receive time."
|
||||||
|
)
|
||||||
|
observed_digest = verify_profile(approval.profile)
|
||||||
|
if observed_digest != approval.profile_sha256:
|
||||||
|
raise XRechnungValidationError(
|
||||||
|
"The installed XRechnung profile no longer matches its approval evidence."
|
||||||
|
)
|
||||||
|
return approval.profile
|
||||||
|
|
||||||
|
|
||||||
|
def _aware(value: datetime, label: str) -> None:
|
||||||
|
if value.tzinfo is None or value.utcoffset() is None:
|
||||||
|
raise ValueError(f"XRechnung {label.replace('_', ' ')} must be timezone-aware.")
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["KoSITProfileApproval", "KoSITProfileRegistry", "ProfileApprovalStatus"]
|
||||||
@@ -0,0 +1,441 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from defusedxml import ElementTree
|
||||||
|
|
||||||
|
|
||||||
|
VARL_NAMESPACE = "http://www.xoev.de/de/validator/varl/1"
|
||||||
|
MAX_INVOICE_BYTES = 20 * 1024 * 1024
|
||||||
|
MAX_RUNNER_OUTPUT_BYTES = 2 * 1024 * 1024
|
||||||
|
MAX_REPORT_BYTES = 16 * 1024 * 1024
|
||||||
|
_TECHNICAL_OUTPUT = re.compile(
|
||||||
|
rb"(?im)(?:^|\n)\s*(?:\[[^\]]*\]\s*)?(?:ERROR|FATAL)\b|\b(?:Exception|StackOverflowError|OutOfMemoryError)\b"
|
||||||
|
)
|
||||||
|
|
||||||
|
TechnicalOutcome = Literal["complete", "failed", "incomplete"]
|
||||||
|
ConformanceOutcome = Literal["valid", "invalid", "unknown"]
|
||||||
|
AssessmentOutcome = Literal["accept", "reject", "unknown"]
|
||||||
|
|
||||||
|
|
||||||
|
class XRechnungValidationError(RuntimeError):
|
||||||
|
"""Stable validation or configuration error without invoice contents."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class KoSITValidationProfile:
|
||||||
|
"""Exact locally installed validator and rule-set selection."""
|
||||||
|
|
||||||
|
profile_id: str
|
||||||
|
xrechnung_version: str
|
||||||
|
validator_version: str
|
||||||
|
configuration_version: str
|
||||||
|
java_executable: Path
|
||||||
|
validator_jar: Path
|
||||||
|
validator_jar_sha256: str
|
||||||
|
configuration_root: Path
|
||||||
|
configuration_tree_sha256: str
|
||||||
|
scenarios_file: Path
|
||||||
|
minimum_validation_steps: int
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
for name in (
|
||||||
|
"profile_id",
|
||||||
|
"xrechnung_version",
|
||||||
|
"validator_version",
|
||||||
|
"configuration_version",
|
||||||
|
):
|
||||||
|
value = str(getattr(self, name) or "").strip()
|
||||||
|
if not value or len(value) > 255:
|
||||||
|
raise ValueError(f"XRechnung {name.replace('_', ' ')} is required and limited to 255 characters.")
|
||||||
|
object.__setattr__(self, name, value)
|
||||||
|
for name in ("validator_jar_sha256", "configuration_tree_sha256"):
|
||||||
|
object.__setattr__(self, name, _sha256_text(getattr(self, name), name))
|
||||||
|
for name in ("java_executable", "validator_jar", "configuration_root", "scenarios_file"):
|
||||||
|
path = Path(getattr(self, name))
|
||||||
|
if not path.is_absolute():
|
||||||
|
raise ValueError(f"XRechnung {name.replace('_', ' ')} must be an absolute path.")
|
||||||
|
object.__setattr__(self, name, path)
|
||||||
|
if self.minimum_validation_steps < 1:
|
||||||
|
raise ValueError("XRechnung minimum_validation_steps must be positive.")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class InboundInvoice:
|
||||||
|
tenant_id: str
|
||||||
|
source_reference: str
|
||||||
|
document: bytes
|
||||||
|
received_at: datetime
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if not self.tenant_id.strip() or not self.source_reference.strip():
|
||||||
|
raise ValueError("Inbound invoices require tenant and source references.")
|
||||||
|
if not self.document or len(self.document) > MAX_INVOICE_BYTES:
|
||||||
|
raise ValueError(f"Inbound invoice XML must contain 1 to {MAX_INVOICE_BYTES} bytes.")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def document_sha256(self) -> str:
|
||||||
|
return hashlib.sha256(self.document).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ValidationDiagnostic:
|
||||||
|
level: str
|
||||||
|
code: str | None
|
||||||
|
text: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class XRechnungValidationResult:
|
||||||
|
profile_id: str
|
||||||
|
profile_sha256: str
|
||||||
|
document_sha256: str
|
||||||
|
technical_outcome: TechnicalOutcome
|
||||||
|
conformance: ConformanceOutcome
|
||||||
|
assessment: AssessmentOutcome
|
||||||
|
observed_at: datetime
|
||||||
|
report_sha256: str | None
|
||||||
|
validation_step_count: int
|
||||||
|
diagnostics: tuple[ValidationDiagnostic, ...]
|
||||||
|
technical_reason: str | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def handoff_allowed(self) -> bool:
|
||||||
|
return (
|
||||||
|
self.technical_outcome == "complete"
|
||||||
|
and self.conformance == "valid"
|
||||||
|
and self.assessment == "accept"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ValidatedInvoiceHandoff:
|
||||||
|
tenant_id: str
|
||||||
|
source_reference: str
|
||||||
|
document_sha256: str
|
||||||
|
report_sha256: str
|
||||||
|
validation_profile_id: str
|
||||||
|
validation_profile_sha256: str
|
||||||
|
validated_at: datetime
|
||||||
|
handoff_sha256: str
|
||||||
|
|
||||||
|
|
||||||
|
class KoSITValidator:
|
||||||
|
def __init__(self, profile: KoSITValidationProfile, *, timeout_seconds: int = 120) -> None:
|
||||||
|
if not 1 <= timeout_seconds <= 600:
|
||||||
|
raise ValueError("KoSIT timeout must be between 1 and 600 seconds.")
|
||||||
|
self.profile = profile
|
||||||
|
self.timeout_seconds = timeout_seconds
|
||||||
|
|
||||||
|
def validate(self, invoice: InboundInvoice) -> XRechnungValidationResult:
|
||||||
|
profile_sha256 = verify_profile(self.profile)
|
||||||
|
_verify_safe_xml(invoice.document)
|
||||||
|
with tempfile.TemporaryDirectory(prefix="govoplan-xrechnung-") as temporary:
|
||||||
|
workdir = Path(temporary)
|
||||||
|
input_file = workdir / "invoice.xml"
|
||||||
|
input_file.write_bytes(invoice.document)
|
||||||
|
command = [
|
||||||
|
str(self.profile.java_executable),
|
||||||
|
"-jar",
|
||||||
|
str(self.profile.validator_jar),
|
||||||
|
"-s",
|
||||||
|
str(self.profile.scenarios_file),
|
||||||
|
"-r",
|
||||||
|
str(workdir),
|
||||||
|
"-h",
|
||||||
|
str(input_file),
|
||||||
|
]
|
||||||
|
env = {"LANG": "C.UTF-8", "LC_ALL": "C.UTF-8", "TZ": "UTC"}
|
||||||
|
if os.environ.get("JAVA_HOME"):
|
||||||
|
env["JAVA_HOME"] = os.environ["JAVA_HOME"]
|
||||||
|
try:
|
||||||
|
completed = subprocess.run( # noqa: S603 - absolute, digest-pinned executable and artifacts; no shell.
|
||||||
|
command,
|
||||||
|
cwd=self.profile.configuration_root,
|
||||||
|
env=env,
|
||||||
|
stdin=subprocess.DEVNULL,
|
||||||
|
capture_output=True,
|
||||||
|
check=False,
|
||||||
|
timeout=self.timeout_seconds,
|
||||||
|
)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return _technical_result(
|
||||||
|
self.profile,
|
||||||
|
profile_sha256,
|
||||||
|
invoice.document_sha256,
|
||||||
|
"failed",
|
||||||
|
"KoSIT validation timed out; no handoff is allowed.",
|
||||||
|
)
|
||||||
|
output = completed.stdout + b"\n" + completed.stderr
|
||||||
|
if len(output) > MAX_RUNNER_OUTPUT_BYTES:
|
||||||
|
return _technical_result(
|
||||||
|
self.profile,
|
||||||
|
profile_sha256,
|
||||||
|
invoice.document_sha256,
|
||||||
|
"failed",
|
||||||
|
"KoSIT runner output exceeded the safety limit.",
|
||||||
|
)
|
||||||
|
report_path = workdir / "invoice-report.xml"
|
||||||
|
report = report_path.read_bytes() if report_path.is_file() else None
|
||||||
|
return interpret_kosit_result(
|
||||||
|
profile=self.profile,
|
||||||
|
profile_sha256=profile_sha256,
|
||||||
|
document_sha256=invoice.document_sha256,
|
||||||
|
return_code=completed.returncode,
|
||||||
|
runner_output=output,
|
||||||
|
report=report,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_profile(profile: KoSITValidationProfile) -> str:
|
||||||
|
if not profile.java_executable.is_file() or not os.access(profile.java_executable, os.X_OK):
|
||||||
|
raise XRechnungValidationError("The pinned Java executable is unavailable or not executable.")
|
||||||
|
if not profile.validator_jar.is_file():
|
||||||
|
raise XRechnungValidationError("The pinned KoSIT validator JAR is unavailable.")
|
||||||
|
if _file_sha256(profile.validator_jar) != profile.validator_jar_sha256:
|
||||||
|
raise XRechnungValidationError("The KoSIT validator JAR digest does not match the approved profile.")
|
||||||
|
root = profile.configuration_root.resolve()
|
||||||
|
if not root.is_dir():
|
||||||
|
raise XRechnungValidationError("The pinned KoSIT configuration root is unavailable.")
|
||||||
|
scenarios = profile.scenarios_file.resolve()
|
||||||
|
if not scenarios.is_file() or not scenarios.is_relative_to(root):
|
||||||
|
raise XRechnungValidationError("The scenarios file must exist inside the pinned configuration root.")
|
||||||
|
observed_tree = configuration_tree_sha256(root)
|
||||||
|
if observed_tree != profile.configuration_tree_sha256:
|
||||||
|
raise XRechnungValidationError("The KoSIT configuration tree digest does not match the approved profile.")
|
||||||
|
descriptor = {
|
||||||
|
"profile_id": profile.profile_id,
|
||||||
|
"xrechnung_version": profile.xrechnung_version,
|
||||||
|
"validator_version": profile.validator_version,
|
||||||
|
"configuration_version": profile.configuration_version,
|
||||||
|
"validator_jar_sha256": profile.validator_jar_sha256,
|
||||||
|
"configuration_tree_sha256": profile.configuration_tree_sha256,
|
||||||
|
"scenarios_file": str(scenarios.relative_to(root)),
|
||||||
|
"minimum_validation_steps": profile.minimum_validation_steps,
|
||||||
|
}
|
||||||
|
return hashlib.sha256(_canonical_json(descriptor)).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def configuration_tree_sha256(root: Path) -> str:
|
||||||
|
resolved = root.resolve()
|
||||||
|
if not resolved.is_dir():
|
||||||
|
raise XRechnungValidationError("KoSIT configuration tree is unavailable.")
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
files = sorted(resolved.rglob("*"), key=lambda item: item.relative_to(resolved).as_posix())
|
||||||
|
for path in files:
|
||||||
|
if path.is_symlink():
|
||||||
|
raise XRechnungValidationError("KoSIT configuration trees must not contain symbolic links.")
|
||||||
|
if path.is_dir():
|
||||||
|
continue
|
||||||
|
if not path.is_file():
|
||||||
|
raise XRechnungValidationError("KoSIT configuration trees may contain only regular files and directories.")
|
||||||
|
relative = path.relative_to(resolved).as_posix().encode("utf-8")
|
||||||
|
digest.update(len(relative).to_bytes(4, "big"))
|
||||||
|
digest.update(relative)
|
||||||
|
digest.update(bytes.fromhex(_file_sha256(path)))
|
||||||
|
return digest.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def interpret_kosit_result(
|
||||||
|
*,
|
||||||
|
profile: KoSITValidationProfile,
|
||||||
|
profile_sha256: str,
|
||||||
|
document_sha256: str,
|
||||||
|
return_code: int,
|
||||||
|
runner_output: bytes,
|
||||||
|
report: bytes | None,
|
||||||
|
) -> XRechnungValidationResult:
|
||||||
|
if return_code != 0:
|
||||||
|
return _technical_result(
|
||||||
|
profile,
|
||||||
|
profile_sha256,
|
||||||
|
document_sha256,
|
||||||
|
"failed",
|
||||||
|
f"KoSIT runner exited with status {return_code}; report semantics are not trusted.",
|
||||||
|
)
|
||||||
|
if _TECHNICAL_OUTPUT.search(runner_output):
|
||||||
|
return _technical_result(
|
||||||
|
profile,
|
||||||
|
profile_sha256,
|
||||||
|
document_sha256,
|
||||||
|
"incomplete",
|
||||||
|
"KoSIT runner reported a technical error; a generated report is not trusted.",
|
||||||
|
)
|
||||||
|
if report is None or not report or len(report) > MAX_REPORT_BYTES:
|
||||||
|
return _technical_result(
|
||||||
|
profile,
|
||||||
|
profile_sha256,
|
||||||
|
document_sha256,
|
||||||
|
"incomplete",
|
||||||
|
"KoSIT did not produce a bounded XML report.",
|
||||||
|
)
|
||||||
|
report_sha256 = hashlib.sha256(report).hexdigest()
|
||||||
|
try:
|
||||||
|
root = ElementTree.fromstring(report)
|
||||||
|
except ElementTree.ParseError:
|
||||||
|
return _technical_result(
|
||||||
|
profile,
|
||||||
|
profile_sha256,
|
||||||
|
document_sha256,
|
||||||
|
"incomplete",
|
||||||
|
"KoSIT produced malformed report XML.",
|
||||||
|
report_sha256=report_sha256,
|
||||||
|
)
|
||||||
|
if root.tag != f"{{{VARL_NAMESPACE}}}report" or root.get("valid") not in {"true", "false"}:
|
||||||
|
return _technical_result(
|
||||||
|
profile,
|
||||||
|
profile_sha256,
|
||||||
|
document_sha256,
|
||||||
|
"incomplete",
|
||||||
|
"KoSIT report is missing the expected VARL report identity or validity flag.",
|
||||||
|
report_sha256=report_sha256,
|
||||||
|
)
|
||||||
|
scenario = root.find(f"{{{VARL_NAMESPACE}}}scenarioMatched")
|
||||||
|
steps = root.findall(f".//{{{VARL_NAMESPACE}}}validationStepResult")
|
||||||
|
assessment = root.find(f"{{{VARL_NAMESPACE}}}assessment")
|
||||||
|
accepts = [] if assessment is None else assessment.findall(f"{{{VARL_NAMESPACE}}}accept")
|
||||||
|
rejects = [] if assessment is None else assessment.findall(f"{{{VARL_NAMESPACE}}}reject")
|
||||||
|
steps_well_formed = all(item.get("valid") in {"true", "false"} for item in steps)
|
||||||
|
if (
|
||||||
|
scenario is None
|
||||||
|
or len(steps) < profile.minimum_validation_steps
|
||||||
|
or not steps_well_formed
|
||||||
|
or (len(accepts), len(rejects)) not in {(1, 0), (0, 1)}
|
||||||
|
):
|
||||||
|
return _technical_result(
|
||||||
|
profile,
|
||||||
|
profile_sha256,
|
||||||
|
document_sha256,
|
||||||
|
"incomplete",
|
||||||
|
"KoSIT report is partial or lacks the configured validation-step and assessment evidence.",
|
||||||
|
report_sha256=report_sha256,
|
||||||
|
validation_step_count=len(steps),
|
||||||
|
)
|
||||||
|
accepted = len(accepts) == 1
|
||||||
|
valid_flag = root.get("valid") == "true"
|
||||||
|
diagnostics = tuple(
|
||||||
|
ValidationDiagnostic(
|
||||||
|
level=(item.get("level") or "unknown")[:50],
|
||||||
|
code=(item.get("code") or "")[:100] or None,
|
||||||
|
text=" ".join("".join(item.itertext()).split())[:1000],
|
||||||
|
)
|
||||||
|
for item in root.findall(f".//{{{VARL_NAMESPACE}}}message")[:1000]
|
||||||
|
)
|
||||||
|
return XRechnungValidationResult(
|
||||||
|
profile_id=profile.profile_id,
|
||||||
|
profile_sha256=profile_sha256,
|
||||||
|
document_sha256=document_sha256,
|
||||||
|
technical_outcome="complete",
|
||||||
|
conformance="valid" if valid_flag else "invalid",
|
||||||
|
assessment="accept" if accepted else "reject",
|
||||||
|
observed_at=datetime.now(UTC),
|
||||||
|
report_sha256=report_sha256,
|
||||||
|
validation_step_count=len(steps),
|
||||||
|
diagnostics=diagnostics,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def create_validated_handoff(
|
||||||
|
invoice: InboundInvoice,
|
||||||
|
result: XRechnungValidationResult,
|
||||||
|
) -> ValidatedInvoiceHandoff:
|
||||||
|
if result.document_sha256 != invoice.document_sha256:
|
||||||
|
raise XRechnungValidationError("Validation result belongs to another invoice document.")
|
||||||
|
if not result.handoff_allowed or not result.report_sha256:
|
||||||
|
raise XRechnungValidationError(
|
||||||
|
"Invoice handoff requires technically complete and semantically valid XRechnung evidence."
|
||||||
|
)
|
||||||
|
payload = {
|
||||||
|
"tenant_id": invoice.tenant_id,
|
||||||
|
"source_reference": invoice.source_reference,
|
||||||
|
"document_sha256": invoice.document_sha256,
|
||||||
|
"report_sha256": result.report_sha256,
|
||||||
|
"validation_profile_id": result.profile_id,
|
||||||
|
"validation_profile_sha256": result.profile_sha256,
|
||||||
|
"validated_at": result.observed_at.isoformat(),
|
||||||
|
}
|
||||||
|
return ValidatedInvoiceHandoff(
|
||||||
|
tenant_id=invoice.tenant_id,
|
||||||
|
source_reference=invoice.source_reference,
|
||||||
|
document_sha256=invoice.document_sha256,
|
||||||
|
report_sha256=result.report_sha256,
|
||||||
|
validation_profile_id=result.profile_id,
|
||||||
|
validation_profile_sha256=result.profile_sha256,
|
||||||
|
validated_at=result.observed_at,
|
||||||
|
handoff_sha256=hashlib.sha256(_canonical_json(payload)).hexdigest(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _verify_safe_xml(document: bytes) -> None:
|
||||||
|
try:
|
||||||
|
ElementTree.fromstring(document)
|
||||||
|
except ElementTree.ParseError as exc:
|
||||||
|
raise XRechnungValidationError("Inbound invoice is not well-formed safe XML.") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _technical_result(
|
||||||
|
profile: KoSITValidationProfile,
|
||||||
|
profile_sha256: str,
|
||||||
|
document_sha256: str,
|
||||||
|
outcome: Literal["failed", "incomplete"],
|
||||||
|
reason: str,
|
||||||
|
*,
|
||||||
|
report_sha256: str | None = None,
|
||||||
|
validation_step_count: int = 0,
|
||||||
|
) -> XRechnungValidationResult:
|
||||||
|
return XRechnungValidationResult(
|
||||||
|
profile_id=profile.profile_id,
|
||||||
|
profile_sha256=profile_sha256,
|
||||||
|
document_sha256=document_sha256,
|
||||||
|
technical_outcome=outcome,
|
||||||
|
conformance="unknown",
|
||||||
|
assessment="unknown",
|
||||||
|
observed_at=datetime.now(UTC),
|
||||||
|
report_sha256=report_sha256,
|
||||||
|
validation_step_count=validation_step_count,
|
||||||
|
diagnostics=(),
|
||||||
|
technical_reason=reason,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _file_sha256(path: Path) -> str:
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
with path.open("rb") as handle:
|
||||||
|
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||||
|
digest.update(chunk)
|
||||||
|
return digest.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _sha256_text(value: str, label: str) -> str:
|
||||||
|
normalized = str(value or "").strip().lower().removeprefix("sha256:")
|
||||||
|
if len(normalized) != 64 or any(character not in "0123456789abcdef" for character in normalized):
|
||||||
|
raise ValueError(f"XRechnung {label.replace('_', ' ')} must be a SHA-256 digest.")
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical_json(value: object) -> bytes:
|
||||||
|
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"InboundInvoice",
|
||||||
|
"KoSITValidationProfile",
|
||||||
|
"KoSITValidator",
|
||||||
|
"ValidatedInvoiceHandoff",
|
||||||
|
"ValidationDiagnostic",
|
||||||
|
"XRechnungValidationError",
|
||||||
|
"XRechnungValidationResult",
|
||||||
|
"configuration_tree_sha256",
|
||||||
|
"create_validated_handoff",
|
||||||
|
"interpret_kosit_result",
|
||||||
|
"verify_profile",
|
||||||
|
]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import replace
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
import hashlib
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from govoplan_xrechnung.backend.profiles import KoSITProfileApproval, KoSITProfileRegistry
|
||||||
|
from govoplan_xrechnung.backend.validation import (
|
||||||
|
KoSITValidationProfile,
|
||||||
|
XRechnungValidationError,
|
||||||
|
configuration_tree_sha256,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _profile(tmp_path: Path, profile_id: str = "profile-a") -> KoSITValidationProfile:
|
||||||
|
root = tmp_path / profile_id
|
||||||
|
root.mkdir()
|
||||||
|
java = root / "java"
|
||||||
|
java.write_bytes(b"#!/bin/sh\n")
|
||||||
|
java.chmod(0o700)
|
||||||
|
jar = root / "validator.jar"
|
||||||
|
jar.write_bytes(b"validator")
|
||||||
|
config = root / "config"
|
||||||
|
config.mkdir()
|
||||||
|
scenarios = config / "scenarios.xml"
|
||||||
|
scenarios.write_text("<scenarios/>", encoding="utf-8")
|
||||||
|
return KoSITValidationProfile(
|
||||||
|
profile_id=profile_id,
|
||||||
|
xrechnung_version="configured-version",
|
||||||
|
validator_version="configured-validator",
|
||||||
|
configuration_version="configured-rules",
|
||||||
|
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=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _approval(tmp_path: Path) -> KoSITProfileApproval:
|
||||||
|
return KoSITProfileApproval.approve(
|
||||||
|
_profile(tmp_path),
|
||||||
|
approved_at=datetime(2026, 8, 23, tzinfo=UTC),
|
||||||
|
approved_by="configuration-board",
|
||||||
|
decision_reference="decision:xrechnung-profile-a",
|
||||||
|
accept_from=datetime(2026, 9, 1, tzinfo=UTC),
|
||||||
|
accept_until=datetime(2027, 1, 1, tzinfo=UTC),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_registry_requires_explicit_selection_without_default(tmp_path: Path) -> None:
|
||||||
|
registry = KoSITProfileRegistry((_approval(tmp_path),))
|
||||||
|
|
||||||
|
with pytest.raises(XRechnungValidationError, match="no default"):
|
||||||
|
registry.select(received_at=datetime(2026, 10, 1, tzinfo=UTC))
|
||||||
|
|
||||||
|
|
||||||
|
def test_explicit_or_configured_default_selects_only_inside_window(tmp_path: Path) -> None:
|
||||||
|
approval = _approval(tmp_path)
|
||||||
|
registry = KoSITProfileRegistry(
|
||||||
|
(approval,),
|
||||||
|
default_profile_id=approval.profile.profile_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
selected = registry.select(received_at=datetime(2026, 10, 1, tzinfo=UTC))
|
||||||
|
assert selected.profile_id == "profile-a"
|
||||||
|
|
||||||
|
with pytest.raises(XRechnungValidationError, match="receive time"):
|
||||||
|
registry.select(received_at=datetime(2027, 1, 1, tzinfo=UTC))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("status", ["suspended", "retired"])
|
||||||
|
def test_non_approved_profile_cannot_be_selected(tmp_path: Path, status: str) -> None:
|
||||||
|
approval = replace(_approval(tmp_path), status=status)
|
||||||
|
registry = KoSITProfileRegistry((approval,), default_profile_id="profile-a")
|
||||||
|
|
||||||
|
with pytest.raises(XRechnungValidationError, match="not approved"):
|
||||||
|
registry.select(received_at=datetime(2026, 10, 1, tzinfo=UTC))
|
||||||
|
|
||||||
|
|
||||||
|
def test_changed_artifact_is_rejected_after_approval(tmp_path: Path) -> None:
|
||||||
|
approval = _approval(tmp_path)
|
||||||
|
registry = KoSITProfileRegistry((approval,), default_profile_id="profile-a")
|
||||||
|
approval.profile.validator_jar.write_bytes(b"changed")
|
||||||
|
|
||||||
|
with pytest.raises(XRechnungValidationError, match="digest"):
|
||||||
|
registry.select(received_at=datetime(2026, 10, 1, tzinfo=UTC))
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_must_reference_allow_list(tmp_path: Path) -> None:
|
||||||
|
with pytest.raises(ValueError, match="not present"):
|
||||||
|
KoSITProfileRegistry((_approval(tmp_path),), default_profile_id="missing")
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
import hashlib
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from govoplan_xrechnung.backend.manifest import get_manifest
|
||||||
|
from govoplan_xrechnung.backend.validation import (
|
||||||
|
InboundInvoice,
|
||||||
|
KoSITValidationProfile,
|
||||||
|
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 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.20"
|
||||||
|
assert "none is activated by default" in manifest.architecture.known_limits[0].lower()
|
||||||
Reference in New Issue
Block a user