#!/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. 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:" """ from __future__ import annotations import os 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, # die in derselben Sekunde geschrieben oder zurückgesetzt wird, liefe sonst mit dem alten # Bytecode: Mutation wirkt nicht bzw. bleibt nach dem Lauf aktiv (llm-server, 2026-10-17). TEST_ENV = {**os.environ, "PYTHONDONTWRITEBYTECODE": "1"} 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) 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 = subprocess.run(test_cmd, cwd=workdir, capture_output=True, text=True, env=TEST_ENV) # noqa: S603 # nosec B603 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: target = base / mutation["file"] original = target.read_text(encoding="utf-8") count = original.count(mutation["old"]) if count != 1: print(f"[FEHLER] {mutation['name']}: Suchtext {count}x gefunden (erwartet 1) – Mutation veraltet") survivors.append(mutation["name"]) continue try: _write(target, original.replace(mutation["old"], mutation["new"])) result = subprocess.run(test_cmd, cwd=workdir, capture_output=True, text=True, env=TEST_ENV) # noqa: S603 # nosec B603 finally: _write(target, original) if result.returncode == 0: print(f"[ÜBERLEBT] {mutation['name']}: Tests bleiben grün") survivors.append(mutation["name"]) else: # pytest: "FAILED ...", node:test: "✖ " (Suiten und Zusammenfassung ausblenden) failed = [ line.strip() for line in result.stdout.splitlines() if line.startswith("FAILED") or (line.startswith(" ✖") and "failing tests" not in line) ] reason = failed[0] if failed else "Tests rot – Ursache prüfen (Syntaxfehler?):\n" + result.stdout[-800:] print(f"[GETÖTET] {mutation['name']}: {reason}") 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()))