#!/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:" """ # noqa: E501, RUF100 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, 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) def _failed_lines(stdout: str) -> list[str]: # pytest: "FAILED ...", node:test: "✖ " (ohne Suiten und Zusammenfassung) lines = [] for line in stdout.splitlines(): node_fail = line.startswith(" ✖") and "failing tests" not in line if line.startswith("FAILED") or node_fail: lines.append(line.strip()) return lines 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) if result.returncode == 0: print(f"[ÜBERLEBT] {name}: Tests bleiben grün") survivors.append(name) else: failed = _failed_lines(result.stdout) hint = "Tests rot, Ursache prüfen (Syntaxfehler?):\n" reason = failed[0] if failed else hint + result.stdout[-800:] print(f"[GETÖTET] {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()))