prepare('DELETE FROM audit_log WHERE tenant_id = ?')->execute([$tid]); $pdo->prepare('DELETE FROM notices WHERE tenant_id = ?')->execute([$tid]); $pdo->prepare('DELETE FROM ledger_entries WHERE tenant_id = ?')->execute([$tid]); $pdo->prepare('DELETE FROM participants WHERE tenant_id = ?')->execute([$tid]); $pdo->prepare('DELETE FROM tenant_settings WHERE tenant_id = ?')->execute([$tid]); $pdo->prepare('DELETE FROM tenants WHERE id = ?')->execute([$tid]); } } $pdo = dev_pdo(); $failures = []; $passes = 0; $suffix = bin2hex(random_bytes(4)); $tenantIds = []; try { // Zwei isolierte Test-Mandanten mit je einem Teilnehmer, einer Buchung, // einem Hinweis und einem Audit-Log-Eintrag anlegen. $participantIds = []; foreach (['a', 'b'] as $key) { $stmt = $pdo->prepare('INSERT INTO tenants (slug, name, status) VALUES (?, ?, ?)'); $stmt->execute(["isolation-{$key}-{$suffix}", "Isolation Tenant {$key}", 'active']); $tenantIds[$key] = (int)$pdo->lastInsertId(); $pdo->prepare('INSERT INTO tenant_settings (tenant_id) VALUES (?)')->execute([$tenantIds[$key]]); $stmt = $pdo->prepare( 'INSERT INTO participants (tenant_id, display_name, email, email_norm, active) VALUES (?, ?, ?, ?, 1)' ); $email = "isolation-{$key}-{$suffix}@test.local"; $stmt->execute([$tenantIds[$key], "Isolation {$key}", $email, $email]); $participantIds[$key] = (int)$pdo->lastInsertId(); ledger_record_payment($pdo, $tenantIds[$key], $participantIds[$key], 1000, 'isolation_test'); notices_create($pdo, $tenantIds[$key], "Hinweis {$key}", date('Y-m-d H:i:s', time() + 3600), null); app_audit_log($pdo, $tenantIds[$key], null, 'isolation_test.marker', 'test', null); } $tenantA = $tenantIds['a']; $tenantB = $tenantIds['b']; $participantA = $participantIds['a']; // 1. Teilnehmerlisten sind strikt getrennt. $summariesB = ledger_fetch_participant_summaries($pdo, $tenantB, ['active_only' => true]); $namesB = array_column($summariesB, 'display_name'); m8_assert('tenant B sieht nur eigenen Teilnehmer', in_array('Isolation b', $namesB, true) && !in_array('Isolation a', $namesB, true), $failures, $passes); // 2. Direkter Zugriff auf einen fremden Teilnehmer ueber den falschen Mandanten liefert nichts. $crossSummary = ledger_fetch_participant_summary($pdo, $tenantB, $participantA); m8_assert('fremder Teilnehmer ueber falschen Mandanten nicht lesbar', $crossSummary === null, $failures, $passes); // 3. Letzte Buchungen sind mandantenscoped. $recentB = ledger_fetch_recent_entries($pdo, $tenantB, ['limit' => 50]); $participantIdsInRecentB = array_column($recentB, 'participant_id'); m8_assert('letzte Buchungen zeigen keinen fremden Teilnehmer', !in_array($participantA, $participantIdsInRecentB, true), $failures, $passes); // 4. Aktive Hinweise sind mandantenscoped. $noticeB = notices_fetch_active($pdo, $tenantB); m8_assert('aktiver Hinweis ist der eigene', $noticeB !== null && $noticeB['message'] === 'Hinweis b', $failures, $passes); // 5. Schreibversuch auf einen fremden Teilnehmer ueber den falschen Mandanten schlaegt fehl. $crossWriteFailed = false; try { ledger_record_payment($pdo, $tenantB, $participantA, 500, 'isolation_test'); } catch (Throwable $e) { $crossWriteFailed = true; } m8_assert('Buchung auf fremden Teilnehmer ueber falschen Mandanten schlaegt fehl', $crossWriteFailed, $failures, $passes); // 6. Zugangsvergabe auf einen fremden Teilnehmer ueber den falschen Mandanten schlaegt fehl. $grantResult = saas_grant_participant_access($pdo, $tenantB, $participantA, 'member'); m8_assert('Zugangsvergabe auf fremden Teilnehmer schlaegt fehl', $grantResult['ok'] === false, $failures, $passes); // 7. Storno eines fremden Eintrags ueber den falschen Mandanten aendert nichts. // Geprueft wird der echte Storno-Pfad ledger_void_entry(), den auch // letzteneintraege.php nutzt. $entryIdA = (int)$pdo->query( "SELECT id FROM ledger_entries WHERE tenant_id = {$tenantA} AND participant_id = {$participantA} AND voided_at IS NULL LIMIT 1" )->fetchColumn(); m8_assert('Testeintrag fuer Storno vorhanden', $entryIdA > 0, $failures, $passes); $voidedWrongTenant = ledger_void_entry($pdo, $tenantB, $entryIdA); m8_assert('Storno ueber falschen Mandanten aendert nichts', $voidedWrongTenant === false, $failures, $passes); $stillActive = (string)$pdo->query("SELECT COUNT(*) FROM ledger_entries WHERE id = {$entryIdA} AND voided_at IS NULL")->fetchColumn(); m8_assert('fremder Eintrag ist nach abgelehntem Storno unveraendert', $stillActive === '1', $failures, $passes); $voidedRightTenant = ledger_void_entry($pdo, $tenantA, $entryIdA); m8_assert('Storno ueber den richtigen Mandanten funktioniert', $voidedRightTenant === true, $failures, $passes); // 8. Audit-Log ist mandantenscoped. $auditB = app_fetch_audit_log($pdo, $tenantB, 50); $auditActionsB = array_column($auditB, 'action'); m8_assert('Audit-Log von Mandant B enthaelt nur eigene Eintraege', in_array('isolation_test.marker', $auditActionsB, true) && count($auditB) === 1, $failures, $passes); // 9. Tenant-Settings sind getrennt (unterschiedliche IDs, kein Ueberlauf). $settingsA = saas_fetch_tenant_settings($pdo, $tenantA); $settingsB = saas_fetch_tenant_settings($pdo, $tenantB); m8_assert('tenant_settings sind pro Mandant getrennt', $settingsA['tenant_id'] !== $settingsB['tenant_id'], $failures, $passes); } catch (Throwable $e) { if ($pdo->inTransaction()) { $pdo->rollBack(); } fwrite(STDERR, "M8 tenant isolation check failed to run: {$e->getMessage()}\n"); m8_cleanup($pdo, $tenantIds); exit(1); } m8_cleanup($pdo, $tenantIds); if ($failures !== []) { echo "\nM8 tenant isolation check failed with " . count($failures) . " failure(s):\n"; foreach ($failures as $failure) { echo "- {$failure}\n"; } exit(1); } echo "\nM8 tenant isolation check passed with {$passes} assertions.\n";