#!/usr/bin/env python3 """Mutationsprüfung für Sicherheitstests. Baut gezielt je eine Schutzfunktion aus (Textersetzung), lässt die Tests laufen und erwartet ROT. Danach wird die Datei immer wiederhergestellt. Überlebt eine Mutation (Tests bleiben grün), prüfen die Tests diese Schutzfunktion nicht wirklich. Getötet ist eine Mutation nur, wenn ein Test fehlschlägt (pytest FAILED, unittest FAIL, node:test ✖ bzw. TAP "not ok") und im Fehlerbericht kein Ladefehler steht (Syntax-/Importfehler, siehe LOAD_ERRORS). Rot durch kaputten Code zählt als UNGÜLTIG. Aufruf: mutation_check.py Exit: 0 = jede Mutation hat mindestens einen Test rot gemacht, 1 = sonst Format (Pfade relativ zur TOML-Datei): test_cmd = [".venv/bin/python", "-m", "pytest", "-q", "-x", "-p", "no:cacheprovider"] workdir = "backend" [[mutation]] name = "Objektberechtigung entfernt" file = "backend/app/main.py" old = "if project is None or not can_access(user, project):" new = "if project is None:" """ # noqa: E501, RUF100 from __future__ import annotations import os import re import subprocess # nosec B404 - Tests starten ist der Zweck des Skripts import sys import tomllib from pathlib import Path # Python prüft .pyc nur über Quell-mtime (Sekunden) und Größe. Eine gleich lange # Mutation, in derselben Sekunde geschrieben oder zurückgesetzt, liefe sonst mit altem # Bytecode: sie wirkt nicht oder bleibt nach dem Lauf aktiv (llm-server, 2026-10-17). TEST_ENV = {**os.environ, "PYTHONDONTWRITEBYTECODE": "1"} def _run_tests(test_cmd: list[str], workdir: Path) -> subprocess.CompletedProcess: # Startet die Tests der App ohne Shell, Kommando aus deren Mutations-TOML. return subprocess.run( # noqa: S603, RUF100 # nosec B603 test_cmd, cwd=workdir, capture_output=True, text=True, env=TEST_ENV, check=False, ) def _write(target: Path, text: str) -> None: target.write_text(text, encoding="utf-8") if target.suffix == ".py": for pyc in (target.parent / "__pycache__").glob(f"{target.stem}.*.pyc"): pyc.unlink(missing_ok=True) # "not ok ... # TODO|SKIP": erwartet scheiternder Test (z. B. idorGap), Lauf bleibt grün TAP_DIRECTIVE = re.compile(r"#\s*(TODO|SKIP)\b") def _tap_fail(stripped: str) -> bool: return stripped.startswith("not ok ") and not TAP_DIRECTIVE.search(stripped) def _failed_lines(output: str) -> list[str]: # pytest: "FAILED ...", unittest: "FAIL: ...", node:test: "✖ " ohne # Zusammenfassung bzw. TAP (ältere Node-Versionen, `tsx --test` ohne Terminal): # "not ok - " lines = [] for line in output.splitlines(): stripped = line.strip() node_fail = stripped.startswith("✖") and "failing tests" not in line node_fail = node_fail or _tap_fail(stripped) # "FAILED (errors=1)" ist die Zusammenfassung von unittest, kein Testfehler. pytest_fail = line.startswith("FAILED ") and not line.startswith("FAILED (") if pytest_fail or line.startswith("FAIL:") or node_fail: lines.append(stripped) return lines # Rot durch kaputten Code ist kein Testbeleg (2026-10-27): pytest meldet einen # Ladefehler als ERROR ohne FAILED, node:test bricht alle Tests ab und markiert sie # trotzdem mit ✖. LOAD_ERRORS = ( "SyntaxError", "IndentationError", "TabError", "ImportError", "ModuleNotFoundError", "error during collection", "TransformError", "ERR_MODULE_NOT_FOUND", "Cannot find module", "Cannot find package", ) def _load_error(text: str) -> str | None: return next((marker for marker in LOAD_ERRORS if marker in text), None) def _failure_report(output: str, failed: list[str]) -> str: # Nur der Fehlerbericht, nicht der ganze Lauf: Logs der App sind kein Ladefehler # (Selftest 2026-10-27: Express loggt den JSON-SyntaxError von body-parser beim # Test mit "{kaputt"). node:test: Abschnitt "✖ failing tests:", TAP: die YAML-Blöcke # unter "not ok" (App-Logs stehen als "# ..." außerhalb), pytest/unittest: die # Fehlerzeilen selbst. _, sep, tail = output.partition("✖ failing tests:") if sep: return tail tap, in_block = [], False for line in output.splitlines(): if _tap_fail(line.strip()): in_block = True if in_block: tap.append(line) in_block = line.strip() != "..." return "\n".join(tap) if tap else "\n".join(failed) def run(config_path: Path) -> int: base = config_path.parent config = tomllib.loads(config_path.read_text(encoding="utf-8")) test_cmd = config["test_cmd"] workdir = base / config.get("workdir", ".") mutations = config.get("mutation", []) if not mutations: print("Keine Mutationen definiert.") return 1 # Vorbedingung: ohne Mutation muss alles grün sein, sonst beweist Rot nichts. baseline = _run_tests(test_cmd, workdir) if baseline.returncode != 0: print("Tests sind schon ohne Mutation rot, erst reparieren.\n") print(baseline.stdout[-3000:]) return 1 survivors = [] for mutation in mutations: name = mutation["name"] target = base / mutation["file"] original = target.read_text(encoding="utf-8") count = original.count(mutation["old"]) if count != 1: print(f"[FEHLER] {name}: Suchtext {count}x gefunden (erwartet 1), veraltet") survivors.append(name) continue try: _write(target, original.replace(mutation["old"], mutation["new"])) result = _run_tests(test_cmd, workdir) finally: _write(target, original) output = result.stdout + result.stderr # unittest schreibt auf stderr failed = _failed_lines(output) load_error = _load_error(_failure_report(output, failed)) if result.returncode == 0: print(f"[ÜBERLEBT] {name}: Tests bleiben grün") survivors.append(name) elif load_error or not failed: # Ohne Fehlerzeile (pytest: nur ERROR beim Sammeln): Grund aus ganzem Lauf load_error = load_error or _load_error(output) why = "kein Testfehler erkennbar" if load_error: why = f"Ladefehler ({load_error})" print(f"[UNGÜLTIG] {name}: Tests rot, aber {why}, kein Testbeleg.") print(" Mutation so ändern, dass der Code lauffähig bleibt:") print(output[-800:]) survivors.append(name) else: print(f"[GETÖTET] {name}: {failed[0]}") print(f"\n{len(mutations) - len(survivors)}/{len(mutations)} Mutationen erkannt.") return 1 if survivors else 0 if __name__ == "__main__": if len(sys.argv) != 2: print(__doc__) sys.exit(2) sys.exit(run(Path(sys.argv[1]).resolve()))