Files
kaffeekasse-saas/scripts/http-smoke.php
T
clemensandClaude Opus 4.8 3e24910831 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>
2026-07-21 21:10:09 +02:00

499 lines
16 KiB
PHP

<?php
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'],
],
[
'label' => 'Impressum',
'path' => 'impressum.php',
'contains' => ['Impressum', 'In den Sieben Stücken 9d', '30655 Hannover'],
],
[
'label' => 'AGB',
'path' => 'agb.php',
'contains' => ['Allgemeine Geschäftsbedingungen', 'Entwurf'],
],
[
'label' => 'Preise',
'path' => 'preise.php',
'contains' => ['Preise', '3,99', '7,99', '12,99', '§ 19 UStG'],
],
[
'label' => 'Datenschutzerklärung',
'path' => 'datenschutz.php',
'contains' => ['Datenschutzerklärung', 'Verantwortlicher', 'Auftragsverarbeiter'],
],
[
'label' => 'Dashboard',
'path' => 'index.php',
'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',
'auth' => true,
'contains' => ['Einzahlungen', 'Bemerkung'],
],
[
'label' => 'Kaffeeliste',
'path' => 'kaffeeliste.php',
'auth' => true,
'contains' => [
'Aktive Mitarbeiter',
'teilnehmerauswertung.php?participant_id=',
'Smoke Guthaben',
$erwartetGuthaben,
'Smoke Schulden',
$erwartetSchulden,
'Smoke Ohne Buchungen',
'0,00 €',
],
'not_contains' => ['Smoke Inaktiv'],
],
[
'label' => 'Ledger Preview',
'path' => 'ledger-preview.php',
'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',
'auth' => true,
'contains' => ['Letzte 100 Einzahlungen', 'Stornieren'],
],
[
'label' => 'CSV-Upload',
'path' => 'csvupload.php',
'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'],
],
[
'label' => 'Login',
'path' => 'login.php',
'contains' => ['Login', 'Registrieren', 'Passwort vergessen'],
],
[
'label' => 'Registrierung',
'path' => 'register.php',
'contains' => ['Registrierung', 'Kundenkonto anlegen'],
],
[
'label' => 'Passwort vergessen',
'path' => 'passwort-vergessen.php',
'contains' => ['Passwort vergessen', 'Link vorbereiten'],
],
[
'label' => 'Mandantenauswahl Login-Schutz',
'path' => 'mandant-auswahl.php',
'expect_redirect' => 'login.php',
'contains' => [],
],
[
'label' => 'Passwort zurücksetzen Invalid',
'path' => 'passwort-zuruecksetzen.php',
'contains' => ['Passwort zurücksetzen', 'ungültig oder abgelaufen'],
],
[
'label' => 'E-Mail verifizieren Invalid',
'path' => 'email-verifizieren.php',
'contains' => ['E-Mail verifizieren', 'ungültig oder abgelaufen'],
],
[
'label' => 'Mandant-Einstellungen Login-Schutz',
'path' => 'mandant-einstellungen.php',
'expect_redirect' => 'login.php',
'contains' => [],
],
[
'label' => 'Namensanpassung',
'path' => 'namenanpassen.php',
'auth' => true,
'contains' => ['Anzeigenamen aktualisieren'],
],
[
'label' => 'Datenexport Login-Schutz',
'path' => 'datenexport.php',
'expect_redirect' => 'login.php',
'contains' => [],
],
[
'label' => 'Abo-Upgrade GET-Redirect',
'path' => 'abo-upgrade.php',
'expect_redirect' => 'login.php',
'contains' => [],
],
[
'label' => 'Abo-Portal GET-Redirect',
'path' => 'abo-portal.php',
'expect_redirect' => 'login.php',
'contains' => [],
],
[
'label' => 'Mandant löschen Login-Schutz',
'path' => 'mandant-loeschen.php',
'expect_redirect' => 'login.php',
'contains' => [],
],
[
'label' => 'Back-Office Login-Schutz',
'path' => 'backoffice.php',
'expect_redirect' => 'login.php',
'contains' => [],
],
[
'label' => 'Back-Office Mandantendetail Login-Schutz',
'path' => 'backoffice-mandant.php?tenant_id=1',
'expect_redirect' => 'login.php',
'contains' => [],
],
[
'label' => 'Teilnehmerauswertung',
'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'],
],
];
$knownIssueChecks = [
];
$skippedUnsafe = [
];
/**
* 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' => $method,
'timeout' => 15,
'ignore_errors' => true,
'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 $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) {
$error = error_get_last();
return [
'status' => $status,
'body' => '',
'location' => $location,
'error' => $error['message'] ?? 'Unknown HTTP error',
];
}
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, '/');
}
$failures = [];
$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']);
// 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) {
$failures[] = "{$label}: {$response['error']}";
echo "FAIL {$label}\n";
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";
continue;
}
if (preg_match($issuePattern, $response['body'], $matches) === 1) {
$failures[] = "{$label}: PHP issue marker found ({$matches[0]})";
echo "FAIL {$label}\n";
continue;
}
foreach ($check['contains'] as $needle) {
if (!str_contains($response['body'], $needle)) {
$failures[] = "{$label}: expected text not found: {$needle}";
echo "FAIL {$label}\n";
continue 2;
}
}
foreach (($check['not_contains'] ?? []) as $needle) {
if (str_contains($response['body'], $needle)) {
$failures[] = "{$label}: unexpected text found: {$needle}";
echo "FAIL {$label}\n";
continue 2;
}
}
$passes++;
echo "PASS {$label}\n";
}
foreach ($knownIssueChecks as $check) {
$url = smoke_url($baseUrl, $check['path']);
$response = smoke_fetch($url);
$label = $check['label'] . ' (' . $check['path'] . ')';
if ($response['error'] !== null) {
echo "KNOWN {$label}: {$check['reason']} ({$response['error']})\n";
continue;
}
if (preg_match($check['expected_issue'], $response['body']) === 1) {
echo "KNOWN {$label}: {$check['reason']}\n";
continue;
}
if ($response['status'] === 200 && preg_match($issuePattern, $response['body']) !== 1) {
echo "RESOLVED {$label}: bitte in die regulären Smoke-Checks aufnehmen.\n";
continue;
}
$failures[] = "{$label}: unexpected response while checking known issue";
echo "FAIL {$label}\n";
}
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) {
echo "- {$failure}\n";
}
exit(1);
}
echo "\nHTTP smoke passed with {$passes} checked pages.\n";