cleanupTempPaths(); } /** * Meldet einen Pfad zum automatischen Aufräumen an; tearDown() entfernt ihn * anschließend rekursiv, auch wenn der Test mit einer Ausnahme abbricht. * * Nötig, weil der Rollback nur die Datenbank zurückdreht: eine im Test * vergebene Belegnummer wird danach erneut vergeben, ein liegengebliebenes * Verzeichnis blockiert dann den echten Upload. */ protected function registerTempPath(string $path): string { $this->tempPaths[] = $path; return $path; } /** * Legt ein Verzeichnis an und meldet es zum Aufräumen an. */ protected function makeTempDir(string $path, int $mode = 0755): string { if (is_dir($path)) { return $this->registerTempPath($path); } // Auch die von mkdir() nebenbei angelegten Elternverzeichnisse anmelden – // sonst bleibt etwa ein leeres Jahresverzeichnis zurück. $oberstesNeue = $path; while (!is_dir(dirname($oberstesNeue)) && dirname($oberstesNeue) !== $oberstesNeue) { $oberstesNeue = dirname($oberstesNeue); } mkdir($path, $mode, true); $this->registerTempPath($path); if ($oberstesNeue !== $path) { $this->registerTempPath($oberstesNeue); } return $path; } /** * Liefert einen eindeutigen, noch nicht existierenden Pfad im System-Temp- * Verzeichnis und meldet ihn zum Aufräumen an. */ protected function tempPath(string $suffix = ''): string { $name = 'steuerkram_test_' . bin2hex(random_bytes(6)) . $suffix; return $this->registerTempPath(sys_get_temp_dir() . '/' . $name); } protected function cleanupTempPaths(): void { // Umgekehrte Reihenfolge: verschachtelte Pfade vor ihren Elternverzeichnissen foreach (array_reverse($this->tempPaths) as $path) { if (!file_exists($path) && !is_link($path)) { continue; } if (!self::isInsideCleanupRoot($path)) { // Nicht durch $quiet unterdrückt: Test 79 prüft diese Meldung gezielt // über ob_start()/ob_get_clean() ihrer eigenen TestCase-Instanz. echo " \e[33m!\e[0m Nicht aufgeräumt (außerhalb der Testverzeichnisse): {$path}\n"; continue; } self::removeRecursive($path); } $this->tempPaths = []; } /** * Sicherheitsnetz: entfernt wird ausschließlich unterhalb der Temp- und * Datenverzeichnisse – und nie eines dieser Verzeichnisse selbst. */ private static function isInsideCleanupRoot(string $path): bool { $real = realpath($path); if ($real === false) { $real = realpath(dirname($path)); if ($real === false) return false; $real .= '/' . basename($path); } foreach ([sys_get_temp_dir(), \Steuerkram\Config::get('upload_dir'), \Steuerkram\Config::get('logo_dir')] as $root) { $rootReal = is_string($root) && $root !== '' ? realpath($root) : false; if ($rootReal !== false && str_starts_with($real, $rootReal . '/')) { return true; } } return false; } private static function removeRecursive(string $path): void { if (is_link($path) || !is_dir($path)) { @unlink($path); return; } // Tests setzen Verzeichnisse teils schreibgeschützt, um Fehlerfälle zu prüfen @chmod($path, 0755); foreach (scandir($path) ?: [] as $entry) { if ($entry === '.' || $entry === '..') continue; self::removeRecursive($path . '/' . $entry); } @rmdir($path); } protected function assert(bool $condition, string $message): void { if ($condition) { $this->passed++; if (!self::$quiet) { echo " \e[32m✓\e[0m {$message}\n"; } } else { $this->failed++; $this->failures[] = "[{$this->testName}] {$message}"; if (!self::$quiet) { echo " \e[31m✗\e[0m {$message}\n"; } } } protected function assertEquals(mixed $expected, mixed $actual, string $message): void { $this->assert( $expected == $actual, "{$message} (erwartet: " . json_encode($expected) . ", erhalten: " . json_encode($actual) . ")" ); } protected function assertGreaterThan(int $min, mixed $actual, string $message): void { $this->assert($actual > $min, "{$message} (Wert: {$actual}, muss > {$min})"); } protected function assertNotEmpty(mixed $value, string $message): void { $this->assert(!empty($value), $message); } /** * Liefert Konto-ID anhand der Kontonummer oder null wenn nicht gefunden. */ protected function findAccountId(string $kontonummer): ?int { $row = \Steuerkram\Database::fetchOne( 'SELECT id FROM accounts WHERE kontonummer = ?', [$kontonummer] ); return $row ? (int)$row['id'] : null; } public function execute(): array { if (!self::$quiet) { echo "\n\e[1m{$this->testName}\e[0m\n"; } $this->setUp(); try { $this->run(); } catch (\Throwable $e) { $this->failed++; $msg = "[{$this->testName}] Ausnahme: " . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine(); $this->failures[] = $msg; if (!self::$quiet) { echo " \e[31m✗\e[0m Unerwartete Ausnahme: " . $e->getMessage() . "\n"; } } finally { $this->tearDown(); } if (!self::$quiet) { $status = $this->failed === 0 ? "\e[32mBestanden ({$this->passed}/{$this->passed})\e[0m" : "\e[31m{$this->failed} Fehler, {$this->passed} OK\e[0m"; echo " → {$status}\n"; } self::$totalPassed += $this->passed; self::$totalFailed += $this->failed; self::$allFailures = array_merge(self::$allFailures, $this->failures); return $this->failures; } public static function globalSummary(): array { return [ 'passed' => self::$totalPassed, 'failed' => self::$totalFailed, 'failures' => self::$allFailures, ]; } }