Files
clemensandClaude Opus 4.8 d18c55eef5 Formular-Abstaende korrigiert und Anleitungsseite ergaenzt
Abstaende:
Im Template hat das Label 1em Abstand nach unten, das Eingabefeld aber gar
keinen, und die Formular-Grids nutzen keinen vertikalen Gutter (gtr-uniform).
Dadurch war der Abstand vom Feld zum naechsten Label 0, waehrend Label zu
eigenem Feld 1em betrug - das Label wirkte, als gehoere es zum Feld darueber.
Jetzt sitzt das Label eng an seinem eigenen Feld (0,5em) und die Felder haben
1,5em Abstand nach unten.

Ausserdem stylt main.css die Typen number, date, datetime-local und file gar
nicht; diese Felder erschienen als kleine native Kaestchen zwischen den sonst
einheitlichen Feldern (z. B. "Listenfenster in Tagen" und "Zeilenhoehe im
Ausdruck"). Sie bekommen jetzt dasselbe Aussehen wie die Textfelder.

Die Anpassungen liegen in einer eigenen assets/css/app.css, damit main.css als
Template-Datei unveraendert bleibt. Nebenbei: .hint-box.error war nirgends
definiert, obwohl es die haeufigste Variante ist - Fehlermeldungen erschienen
ungefaerbt wie neutrale Hinweise.

In faq.php, hinweise.php, namenanpassen.php und stricheintragen.php standen
<br>/<br><br> als Abstandsersatz hinter Labels und Feldern; mit den neuen
Abstaenden waere daraus doppelter Leerraum geworden. Entfernt, das Layout
kommt jetzt aus dem CSS.

Anleitung:
Neue Seite anleitung.php mit den wichtigsten Funktionen, gegliedert nach
Zielgruppe. Die Abschnitte fuer Kassenwart bzw. Administration werden nur
eingeblendet, wenn die Rolle sie auch nutzen kann - sonst stuenden dort
Hinweise auf Menuepunkte, die es fuer den Nutzer nicht gibt. Verlinkt im Menue
unter "Kaffeeliste" und im Smoke-Test abgedeckt.

Geprueft: alle 12 Formularseiten rendern fehlerfrei, http-smoke 34/0,
role-matrix 55/0, tenant-isolation 12/0, settings-flow 15/0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 11:51:14 +02:00

513 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,
'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'],
],
[
// 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";