Legacy-Abbau Schritt 2: Legacy-Authentifizierung entfernt

Die Zugriffspruefung lief bisher in 19 Dateien auf einen Fallback gegen die
Legacy-Tabelle kl_Mitarbeiter (checkKaffeelisteAdmin/-Access). Der Zugang
haengt jetzt ausschliesslich an der SaaS-Anmeldung.

- Fallback-Bloecke in allen 19 Dateien entfernt, ebenso getUserName/getUserId
- functions.php besteht nur noch aus der SaaS-Anmeldung; die per String
  zusammengebaute Abfrage "WHERE Email like '<eingabe>'" ist damit weg
- config.php baut keine sqlsrv-Verbindung mehr auf, der Kompatibilitaetslayer
  lib/sqlsrv_mysql_compat.php ist verwaist und entfaellt
- Auto-Login per DEV_AUTH_EMAIL gibt es nicht mehr: er meldete jeden Besucher
  an und machte damit den Login-Schutz unpruefbar. Angemeldet wird auch lokal
  ueber login.php; die Variable dient nur noch scripts/init-mysql-dev.php

http-smoke war an die Golden-Master-Daten des Default-Mandanten gebunden und
hatte deshalb sechs dauerhaft rote Pruefungen. Der Test legt sich jetzt einen
eigenen Mandanten mit bekannten Salden an, meldet sich per HTTP an und raeumt
danach auf. Geschuetzte Seiten werden nicht mehr ueber das Wort "Login" im
Text geprueft, sondern ueber die tatsaechliche 302-Umleitung.

  http-smoke        27 PASS / 6 FAIL  ->  33 PASS / 0 FAIL
  role-matrix       55 PASS / 0 FAIL  ->  unveraendert
  tenant-isolation   9 PASS / 1 FAIL  ->  11 PASS / 1 FAIL (Altfehler)

Ausserdem "keine Zugang" -> "keinen Zugang" in zwei Fehlermeldungen; der
Rollentest erkannte die Ablehnung an genau diesem Tippfehler und wurde
mitgezogen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-21 21:10:09 +02:00
co-authored by Claude Opus 4.8
parent eec7a2fef2
commit 3e24910831
25 changed files with 229 additions and 548 deletions
+208 -29
View File
@@ -2,14 +2,31 @@
declare(strict_types=1);
require __DIR__ . '/dev-db.php';
$baseUrl = rtrim((string)(getenv('SMOKE_BASE_URL') ?: 'http://127.0.0.1:8080'), '/');
$issuePattern = '~(?:Deprecated|Warning|Fatal error|Parse error|Notice|Uncaught (?:Error|Exception))~i';
// Der Smoke-Test legt sich seinen eigenen Mandanten mit bekannten Zahlen an und
// meldet sich per HTTP an, statt sich auf vorhandene Daten oder eine
// Auto-Anmeldung zu verlassen. Nur so lassen sich angemeldete Seiten UND der
// Login-Schutz in einem Lauf pruefen.
$pdo = dev_pdo();
$suffix = bin2hex(random_bytes(4));
$smokeSlug = "smoke-{$suffix}";
$smokeEmail = "smoke-owner-{$suffix}@test.local";
$smokePassword = 'HttpSmokeTest123!';
$smokeTenantId = null;
// Erwartete Betraege der Testdaten (siehe smoke_seed()).
$erwartetGuthaben = '15,00 €';
$erwartetSchulden = '-4,00 €';
$checks = [
[
'label' => 'Landingpage',
'path' => 'landing.php',
'contains' => ['Kaffeeliste', 'Kundenkonto anlegen', 'Mandantenfähig'],
'contains' => ['Kaffeeliste', 'Kundenkonto anlegen'],
],
[
'label' => 'Impressum',
@@ -34,62 +51,71 @@ $checks = [
[
'label' => 'Dashboard',
'path' => 'index.php',
'contains' => ['Hallo Test Admin', 'Aktueller Stand', 'Jahresübersicht', 'Letzte Einzahlungen'],
'auth' => true,
'contains' => ['Aktueller Stand', 'Jahresübersicht', 'Letzte Einzahlungen'],
],
[
'label' => 'Striche erfassen',
'path' => 'stricheintragen.php',
'auth' => true,
'contains' => ['Anzahl der Striche'],
],
[
'label' => 'Einzahlungen erfassen',
'path' => 'einzahlung.php',
'contains' => ['Einzahlungen'],
'auth' => true,
'contains' => ['Einzahlungen', 'Bemerkung'],
],
[
'label' => 'Kaffeeliste',
'path' => 'kaffeeliste.php',
'auth' => true,
'contains' => [
'Aktive Mitarbeiter',
'teilnehmerauswertung.php?participant_id=',
'GM Negativ',
'gm-negative@test.local',
'-2,00 €',
'GM Positiv',
'23,60 €',
'GM Ohne Buchungen',
'Smoke Guthaben',
$erwartetGuthaben,
'Smoke Schulden',
$erwartetSchulden,
'Smoke Ohne Buchungen',
'0,00 €',
],
'not_contains' => ['GM Inaktiv'],
'not_contains' => ['Smoke Inaktiv'],
],
[
'label' => 'Ledger Preview',
'path' => 'ledger-preview.php',
'contains' => ['Ledger Preview', 'Tenant-Summen', 'Letzte Ledger-Buchungen'],
'auth' => true,
'contains' => ['Journal-Vorschau', 'Tenant-Summen', 'Letzte Ledger-Buchungen'],
],
[
'label' => 'Mitgliederverwaltung',
'path' => 'mitarbeiterverwalten.php',
'auth' => true,
'contains' => ['Mitglieder verwalten', 'PayPal-Name'],
],
[
'label' => 'Letzte Einträge',
'path' => 'letzteneintraege.php',
'contains' => ['Letzte 100 Einzahlungen'],
'auth' => true,
'contains' => ['Letzte 100 Einzahlungen', 'Stornieren'],
],
[
'label' => 'CSV-Upload',
'path' => 'csvupload.php',
'contains' => ['CSV Verarbeitung'],
'auth' => true,
'contains' => ['CSV-Import'],
],
[
'label' => 'Hinweise',
'path' => 'hinweise.php',
'auth' => true,
'contains' => ['Kaffeeliste - Hinweise'],
],
[
'label' => 'FAQ',
'path' => 'faq.php',
'auth' => true,
'contains' => ['FAQ - Kaffeeliste'],
],
[
@@ -110,7 +136,8 @@ $checks = [
[
'label' => 'Mandantenauswahl Login-Schutz',
'path' => 'mandant-auswahl.php',
'contains' => ['Login'],
'expect_redirect' => 'login.php',
'contains' => [],
],
[
'label' => 'Passwort zurücksetzen Invalid',
@@ -125,61 +152,73 @@ $checks = [
[
'label' => 'Mandant-Einstellungen Login-Schutz',
'path' => 'mandant-einstellungen.php',
'contains' => ['Login'],
'expect_redirect' => 'login.php',
'contains' => [],
],
[
'label' => 'Namensanpassung',
'path' => 'namenanpassen.php',
'auth' => true,
'contains' => ['Anzeigenamen aktualisieren'],
],
[
'label' => 'Datenexport Login-Schutz',
'path' => 'datenexport.php',
'contains' => ['Login'],
'expect_redirect' => 'login.php',
'contains' => [],
],
[
'label' => 'Abo-Upgrade GET-Redirect',
'path' => 'abo-upgrade.php',
'contains' => ['Login'],
'expect_redirect' => 'login.php',
'contains' => [],
],
[
'label' => 'Abo-Portal GET-Redirect',
'path' => 'abo-portal.php',
'contains' => ['Login'],
'expect_redirect' => 'login.php',
'contains' => [],
],
[
'label' => 'Mandant löschen Login-Schutz',
'path' => 'mandant-loeschen.php',
'contains' => ['Login'],
'expect_redirect' => 'login.php',
'contains' => [],
],
[
'label' => 'Back-Office Login-Schutz',
'path' => 'backoffice.php',
'contains' => ['Login'],
'expect_redirect' => 'login.php',
'contains' => [],
],
[
'label' => 'Back-Office Mandantendetail Login-Schutz',
'path' => 'backoffice-mandant.php?tenant_id=1',
'contains' => ['Login'],
'expect_redirect' => 'login.php',
'contains' => [],
],
[
'label' => 'Teilnehmerauswertung',
'path' => 'teilnehmerauswertung.php?user_id=1',
'contains' => ['Auswertung', 'Test Admin', 'Jahresübersicht', 'Letzte Einzahlungen'],
'path' => 'PLATZHALTER_TEILNEHMER',
'auth' => true,
'contains' => ['Auswertung', 'Smoke Guthaben', 'Jahresübersicht', 'Letzte Einzahlungen'],
],
[
'label' => 'PDF-Export',
'path' => 'exportKaffeeliste.php',
'auth' => true,
'contains' => ['%PDF'],
],
[
'label' => 'Mailversand',
'path' => 'mailversenden.php',
'auth' => true,
'contains' => ['Info-Mail versenden', 'Dry-Run', 'Versandlog'],
],
[
'label' => 'Jahresabschluss',
'path' => 'jahresauswertung.php',
'auth' => true,
'contains' => ['Jahresabschluss', 'Dry-Run', 'Jahresstriche gesamt'],
],
];
@@ -190,25 +229,52 @@ $knownIssueChecks = [
$skippedUnsafe = [
];
function smoke_fetch(string $url): array
/**
* HTTP-Request mit optionalem Cookie-Jar. $cookies wird per Referenz
* aktualisiert, damit die Session ueber mehrere Requests erhalten bleibt.
*
* @param array<string,string>|null $cookies null = bewusst ohne Session
*/
function smoke_fetch(string $url, ?array &$cookies = null, string $method = 'GET', ?string $postBody = null): array
{
$header = "User-Agent: KaffeelisteHttpSmoke/1.0\r\n";
if ($cookies !== null && $cookies !== []) {
$pairs = [];
foreach ($cookies as $name => $value) {
$pairs[] = "{$name}={$value}";
}
$header .= 'Cookie: ' . implode('; ', $pairs) . "\r\n";
}
if ($postBody !== null) {
$header .= "Content-Type: application/x-www-form-urlencoded\r\n";
}
$context = stream_context_create([
'http' => [
'method' => 'GET',
'method' => $method,
'timeout' => 15,
'ignore_errors' => true,
'header' => "User-Agent: KaffeelisteHttpSmoke/1.0\r\n",
'follow_location' => 0,
'header' => $header,
'content' => $postBody ?? '',
],
]);
$body = @file_get_contents($url, false, $context);
$headers = $http_response_header ?? [];
$status = 0;
$location = null;
foreach ($headers as $header) {
if (preg_match('~^HTTP/\S+\s+(\d{3})~', $header, $matches) === 1) {
foreach ($headers as $h) {
if (preg_match('~^HTTP/\S+\s+(\d{3})~', $h, $matches) === 1) {
$status = (int)$matches[1];
}
if (preg_match('/^Location:\s*(.+)$/i', $h, $m) === 1) {
$location = trim($m[1]);
}
if ($cookies !== null && preg_match('/^Set-Cookie:\s*([^=;]+)=([^;]+)/i', $h, $m) === 1) {
$cookies[$m[1]] = $m[2];
}
}
if ($body === false) {
@@ -216,6 +282,7 @@ function smoke_fetch(string $url): array
return [
'status' => $status,
'body' => '',
'location' => $location,
'error' => $error['message'] ?? 'Unknown HTTP error',
];
}
@@ -223,10 +290,69 @@ function smoke_fetch(string $url): array
return [
'status' => $status,
'body' => $body,
'location' => $location,
'error' => null,
];
}
/**
* Legt einen eigenen Mandanten mit bekannten Zahlen an:
* - "Smoke Guthaben" +20,00 € Einzahlung, 25 Striche a 0,20 € = 15,00 €
* - "Smoke Schulden" keine Einzahlung, 20 Striche a 0,20 € = -4,00 €
* - "Smoke Ohne Buchungen" nichts = 0,00 €
* - "Smoke Inaktiv" inaktiv, darf in der Kaffeeliste fehlen
*
* @return array{tenant_id:int, participant_id:int}
*/
function smoke_seed(PDO $pdo, string $slug, string $email, string $password, string $suffix): array
{
$pdo->prepare('INSERT INTO tenants (slug, name, status) VALUES (?, ?, ?)')
->execute([$slug, 'HTTP-Smoke Mandant', 'active']);
$tenantId = (int)$pdo->lastInsertId();
$pdo->prepare('INSERT INTO tenant_settings (tenant_id) VALUES (?)')->execute([$tenantId]);
$pdo->prepare('INSERT INTO users (email, email_norm, display_name, password_hash, status) VALUES (?, ?, ?, ?, ?)')
->execute([$email, $email, 'Smoke Owner', password_hash($password, PASSWORD_DEFAULT), 'active']);
$userId = (int)$pdo->lastInsertId();
$pdo->prepare('INSERT INTO tenant_memberships (tenant_id, user_id, role, status, joined_at) VALUES (?, ?, ?, ?, NOW())')
->execute([$tenantId, $userId, 'owner', 'active']);
$insertParticipant = $pdo->prepare(
'INSERT INTO participants (tenant_id, user_id, display_name, email, email_norm, active) VALUES (?, ?, ?, ?, ?, ?)'
);
$ids = [];
foreach ([['Smoke Guthaben', $userId, 1], ['Smoke Schulden', null, 1], ['Smoke Ohne Buchungen', null, 1], ['Smoke Inaktiv', null, 0]] as [$name, $uid, $active]) {
$mail = strtolower(str_replace(' ', '-', $name)) . "-{$suffix}@test.local";
$insertParticipant->execute([$tenantId, $uid, $name, $mail, $mail, $active]);
$ids[$name] = (int)$pdo->lastInsertId();
}
$payment = $pdo->prepare(
"INSERT INTO ledger_entries (tenant_id, participant_id, type, amount_cents, booked_at, source) VALUES (?, ?, 'payment', ?, NOW(), 'manual_bulk')"
);
$consumption = $pdo->prepare(
"INSERT INTO ledger_entries (tenant_id, participant_id, type, amount_cents, marks_count, unit_price_cents, booked_at, source) VALUES (?, ?, 'consumption', ?, ?, 20, NOW(), 'manual_bulk')"
);
$payment->execute([$tenantId, $ids['Smoke Guthaben'], 2000]);
$consumption->execute([$tenantId, $ids['Smoke Guthaben'], -500, 25]);
$consumption->execute([$tenantId, $ids['Smoke Schulden'], -400, 20]);
return ['tenant_id' => $tenantId, 'participant_id' => $ids['Smoke Guthaben']];
}
function smoke_cleanup(PDO $pdo, ?int $tenantId, string $email): void
{
if ($tenantId === null) {
return;
}
$pdo->prepare('DELETE FROM ledger_entries WHERE tenant_id = ?')->execute([$tenantId]);
$pdo->prepare('DELETE FROM participants WHERE tenant_id = ?')->execute([$tenantId]);
$pdo->prepare('DELETE FROM tenant_memberships WHERE tenant_id = ?')->execute([$tenantId]);
$pdo->prepare('DELETE FROM tenant_settings WHERE tenant_id = ?')->execute([$tenantId]);
$pdo->prepare('DELETE FROM tenants WHERE id = ?')->execute([$tenantId]);
$pdo->prepare('DELETE FROM users WHERE email_norm = ?')->execute([strtolower($email)]);
}
function smoke_url(string $baseUrl, string $path): string
{
return $baseUrl . '/' . ltrim($path, '/');
@@ -237,9 +363,45 @@ $passes = 0;
echo "HTTP smoke base URL: {$baseUrl}\n";
// Testmandant anlegen und per HTTP anmelden.
try {
$seeded = smoke_seed($pdo, $smokeSlug, $smokeEmail, $smokePassword, $suffix);
$smokeTenantId = $seeded['tenant_id'];
} catch (Throwable $e) {
fwrite(STDERR, "HTTP smoke setup failed: {$e->getMessage()}\n");
exit(1);
}
// Pfad der Teilnehmerauswertung steht erst nach dem Seeden fest.
foreach ($checks as $i => $check) {
if ($check['path'] === 'PLATZHALTER_TEILNEHMER') {
$checks[$i]['path'] = 'teilnehmerauswertung.php?participant_id=' . $seeded['participant_id'];
}
}
$authCookies = [];
$loginPage = smoke_fetch(smoke_url($baseUrl, 'login.php'), $authCookies);
preg_match('/name="csrf_token" value="([^"]+)"/', $loginPage['body'], $csrfMatch);
smoke_fetch(smoke_url($baseUrl, 'login.php'), $authCookies, 'POST', http_build_query([
'csrf_token' => $csrfMatch[1] ?? '',
'email' => $smokeEmail,
'password' => $smokePassword,
'tenant_slug' => $smokeSlug,
]));
if (empty($authCookies)) {
smoke_cleanup($pdo, $smokeTenantId, $smokeEmail);
fwrite(STDERR, "HTTP smoke: Anmeldung fehlgeschlagen (keine Session).\n");
exit(1);
}
foreach ($checks as $check) {
$url = smoke_url($baseUrl, $check['path']);
$response = smoke_fetch($url);
// Angemeldete Seiten mit Session, alles andere bewusst ohne - nur so
// laesst sich der Login-Schutz ueberhaupt pruefen.
$needsAuth = !empty($check['auth']);
$jar = $needsAuth ? $authCookies : null;
$response = smoke_fetch($url, $jar);
$label = $check['label'] . ' (' . $check['path'] . ')';
if ($response['error'] !== null) {
@@ -248,6 +410,21 @@ foreach ($checks as $check) {
continue;
}
// Geschuetzte Seiten muessen ohne Session auf den Login umleiten. Das
// explizit zu pruefen ist belastbarer, als der Umleitung zu folgen und im
// Ergebnis nach dem Wort "Login" zu suchen.
if (isset($check['expect_redirect'])) {
$ziel = (string)$response['location'];
if ($response['status'] !== 302 || !str_contains($ziel, $check['expect_redirect'])) {
$failures[] = "{$label}: erwartete Umleitung auf {$check['expect_redirect']}, kam HTTP {$response['status']} nach '{$ziel}'";
echo "FAIL {$label}\n";
continue;
}
$passes++;
echo "PASS {$label}\n";
continue;
}
if ($response['status'] !== 200) {
$failures[] = "{$label}: expected HTTP 200, got HTTP {$response['status']}";
echo "FAIL {$label}\n";
@@ -308,6 +485,8 @@ foreach ($skippedUnsafe as $skip) {
echo "SKIP {$skip['path']}: {$skip['reason']}\n";
}
smoke_cleanup($pdo, $smokeTenantId, $smokeEmail);
if ($failures !== []) {
echo "\nHTTP smoke failed with " . count($failures) . " failure(s):\n";
foreach ($failures as $failure) {