Mandanten im Tarif "free" (bis 10 Teilnehmer) zeigen unten rechts eine kleine Marke mit Logo und Adresse der Kaffeeliste; zahlende Mandanten behalten ihre Oberflaeche ohne fremdes Logo. Die Sichtbarkeit haengt allein an tenant_billing.plan_code und wird als reine Leseabfrage geprueft - billing_fetch_or_init() waere ein Schreibzugriff im Seitenaufbau. Laesst sich der Tarif nicht lesen, bleibt die Marke aus. Das Logo ist ein Inline-SVG, das Linkziel kommt aus APP_MARKETING_URL bzw. aus APP_HOST ohne App-Subdomain. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
515 lines
17 KiB
PHP
515 lines
17 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,
|
|
// Der Smoke-Mandant laeuft im kostenlosen Tarif, also muss das
|
|
// Wasserzeichen unten rechts mitkommen.
|
|
'contains' => ['Aktueller Stand', 'Jahresübersicht', 'Letzte Einzahlungen', 'class="plan-watermark"'],
|
|
],
|
|
[
|
|
'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'],
|
|
],
|
|
[
|
|
// Der Smoke-Login ist Owner, deshalb muessen auch die
|
|
// Verwaltungsabschnitte erscheinen.
|
|
'label' => 'Anleitung',
|
|
'path' => 'anleitung.php',
|
|
'auth' => true,
|
|
'contains' => [
|
|
'Anleitung',
|
|
'Für alle Mitglieder',
|
|
'Für Kassenwart und Verwaltung',
|
|
'Für Administratoren',
|
|
'Rollen im Überblick',
|
|
],
|
|
],
|
|
[
|
|
'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";
|