Upload via GUI (452 Dateien)

This commit is contained in:
2026-11-05 21:21:55 +00:00
parent 32f5e780d6
commit 2277cf5493
11 changed files with 751 additions and 17 deletions
@@ -22,7 +22,8 @@
<tbody>
<?php foreach ($recent as $t):
[$label, $color] = status_label($t['status']);
$isOverdue = $t['resolution_due_at'] && strtotime($t['resolution_due_at']) < time();
$isDone = in_array($t['status'], ['resolved','closed'], true);
$isOverdue = !$isDone && $t['resolution_due_at'] && strtotime($t['resolution_due_at']) < time();
?>
<tr class="<?= $isOverdue ? 'is-overdue' : '' ?>">
<td><a href="/staff/ticket_view.php?n=<?= urlencode($t['ticket_number']) ?>" class="ticket-no">#<?= e($t['ticket_number']) ?></a></td>
@@ -34,7 +35,7 @@
<td><?= e($t['department_name']) ?></td>
<td><span class="badge" style="background:<?= e($t['priority_color']) ?>"><?= e($t['priority_name']) ?></span></td>
<td><span class="badge" style="background:<?= $color ?>"><?= e($label) ?></span></td>
<td class="muted"><?= !empty($t['sla_paused_at']) && ticket_sla_is_paused_status((string) $t['status']) ? 'Pausiert' : time_until($t['resolution_due_at']) ?></td>
<td class="muted"><?= $isDone ? '–' : (!empty($t['sla_paused_at']) && ticket_sla_is_paused_status((string) $t['status']) ? 'Pausiert' : time_until($t['resolution_due_at'])) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
+16 -13
View File
@@ -58,7 +58,7 @@ if ('serviceWorker' in navigator) {
<a href="/staff/tickets.php" class="<?= $activePage==='tickets'?'active':'' ?>">Tickets</a>
<a href="/staff/my_week.php" class="<?= $activePage==='my_week'?'active':'' ?>">Meine Woche</a>
<a href="/staff/tasks.php" class="<?= $activePage==='tasks'?'active':'' ?>">Aufgaben</a>
<a href="/staff/banlist.php" class="<?= $activePage==='banlist'?'active':'' ?>">Sperrliste</a>
<?php if (has_permission($staff, 'manage_banlist')): ?><a href="/staff/banlist.php" class="<?= $activePage==='banlist'?'active':'' ?>">Sperrliste</a><?php endif; ?>
<a href="/staff/apps.php" class="<?= $activePage==='apps'?'active':'' ?>">Apps</a>
<?php foreach (plugin_menu_items($staff ?? null) as $__pm): ?>
<a href="<?= e($__pm['url']) ?>" class="<?= ($activePage ?? '') === 'plugin_' . $__pm['plugin'] ? 'active' : '' ?>"><?= e($__pm['label']) ?></a>
@@ -101,15 +101,17 @@ if ('serviceWorker' in navigator) {
<?php if (has_permission($staff, 'manage_departments')): ?><a href="/staff/departments.php" class="<?= $activePage==='departments'?'active':'' ?>">Abteilungen</a><?php endif; ?>
<?php if (has_permission($staff, 'manage_priorities')): ?><a href="/staff/priorities.php" class="<?= $activePage==='priorities'?'active':'' ?>">Prioritäten / SLA</a><a href="/staff/sla_plans.php" class="<?= $activePage==='sla_plans'?'active':'' ?>">SLA-Pläne</a><?php endif; ?>
<?php if (has_permission($staff, 'manage_help_topics')): ?><a href="/staff/help_topics.php" class="<?= $activePage==='help_topics'?'active':'' ?>">Hilfethemen</a><?php endif; ?>
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/custom_fields.php" class="<?= $activePage==='custom_fields'?'active':'' ?>">Ticket-Felder</a><?php endif; ?>
<?php if (has_permission($staff, 'manage_custom_fields')): ?><a href="/staff/custom_fields.php" class="<?= $activePage==='custom_fields'?'active':'' ?>">Ticket-Felder</a><?php endif; ?>
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/api_keys.php" class="<?= $activePage==='api_keys'?'active':'' ?>">API-Schlüssel</a><a href="/staff/webhooks.php" class="<?= $activePage==='webhooks'?'active':'' ?>">Webhooks</a><?php endif; ?>
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/custom_lists.php" class="<?= $activePage==='custom_lists'?'active':'' ?>">Benutzerdefinierte Listen</a><?php endif; ?>
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/canned_responses.php" class="<?= $activePage==='canned'?'active':'' ?>">Vorlagen</a><?php endif; ?>
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/site_pages.php" class="<?= $activePage==='site_pages'?'active':'' ?>">Seiten</a><?php endif; ?>
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/languages.php" class="<?= $activePage==='languages'?'active':'' ?>">Sprachen</a><?php endif; ?>
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/ticket_templates.php" class="<?= $activePage==='ticket_templates'?'active':'' ?>">Ticket-Vorlagen</a><a href="/staff/status_page.php" class="<?= $activePage==='status_page'?'active':'' ?>">Statusseite</a><a href="/staff/problems.php" class="<?= $activePage==='problems'?'active':'' ?>">Problemmanagement</a><?php endif; ?>
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/mail_queue.php" class="<?= $activePage==='mail_queue'?'active':'' ?>">Mail-Warteschlange</a><?php endif; ?>
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/login_log.php" class="<?= $activePage==='login_log'?'active':'' ?>">Anmelde-Wächter</a><?php endif; ?>
<?php if (has_permission($staff, 'manage_custom_fields')): ?><a href="/staff/custom_lists.php" class="<?= $activePage==='custom_lists'?'active':'' ?>">Benutzerdefinierte Listen</a><?php endif; ?>
<?php if (has_permission($staff, 'manage_canned')): ?><a href="/staff/canned_responses.php" class="<?= $activePage==='canned'?'active':'' ?>">Vorlagen</a><?php endif; ?>
<?php if (has_permission($staff, 'manage_site_pages')): ?><a href="/staff/site_pages.php" class="<?= $activePage==='site_pages'?'active':'' ?>">Seiten</a><?php endif; ?>
<?php if (has_permission($staff, 'manage_languages')): ?><a href="/staff/languages.php" class="<?= $activePage==='languages'?'active':'' ?>">Sprachen</a><?php endif; ?>
<?php if (has_permission($staff, 'manage_ticket_templates')): ?><a href="/staff/ticket_templates.php" class="<?= $activePage==='ticket_templates'?'active':'' ?>">Ticket-Vorlagen</a><?php endif; ?>
<?php if (has_permission($staff, 'manage_status_page')): ?><a href="/staff/status_page.php" class="<?= $activePage==='status_page'?'active':'' ?>">Statusseite</a><?php endif; ?>
<?php if (has_permission($staff, 'manage_problems')): ?><a href="/staff/problems.php" class="<?= $activePage==='problems'?'active':'' ?>">Problemmanagement</a><?php endif; ?>
<?php if (has_permission($staff, 'view_mail_queue')): ?><a href="/staff/mail_queue.php" class="<?= $activePage==='mail_queue'?'active':'' ?>">Mail-Warteschlange</a><?php endif; ?>
<?php if (has_permission($staff, 'view_login_guard')): ?><a href="/staff/login_log.php" class="<?= $activePage==='login_log'?'active':'' ?>">Anmelde-Wächter</a><?php endif; ?>
<?php if (has_permission($staff, 'manage_email_addresses')): ?><a href="/staff/email_addresses.php" class="<?= $activePage==='email_addresses'?'active':'' ?>">E-Mail-Adressen</a><?php endif; ?>
<?php if (has_permission($staff, 'manage_email_templates')): ?><a href="/staff/email_templates.php" class="<?= $activePage==='email_templates'?'active':'' ?>">E-Mail-Vorlagen</a><?php endif; ?>
<?php if (has_permission($staff, 'manage_kb')): ?><a href="/staff/kb_manage.php" class="<?= $activePage==='kb'?'active':'' ?>">Wissensdatenbank</a><?php endif; ?>
@@ -120,26 +122,27 @@ if ('serviceWorker' in navigator) {
<a href="<?= e($__pm['url']) ?>" class="<?= ($activePage ?? '') === 'plugin_' . $__pm['plugin'] ? 'active' : '' ?>"><?= e($__pm['label']) ?></a>
<?php endforeach; ?>
<span class="nav-group-label">Kunden</span>
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/customers.php" class="<?= $activePage==='customers'?'active':'' ?>">Kundenverzeichnis</a><?php endif; ?>
<?php if (has_permission($staff, 'manage_customers')): ?><a href="/staff/customers.php" class="<?= $activePage==='customers'?'active':'' ?>">Kundenverzeichnis</a><?php endif; ?>
<?php if (has_permission($staff, 'manage_organizations')): ?><a href="/staff/organizations.php" class="<?= $activePage==='organizations'?'active':'' ?>">Organisationen</a><?php endif; ?>
<span class="nav-group-label">Agenten</span>
<?php if (has_permission($staff, 'manage_staff')): ?><a href="/staff/staff_manage.php" class="<?= $activePage==='staff'?'active':'' ?>">Mitarbeiter</a><?php endif; ?>
<?php if (has_permission($staff, 'manage_teams')): ?><a href="/staff/teams.php" class="<?= $activePage==='teams'?'active':'' ?>">Teams</a><?php endif; ?>
<?php if (has_permission($staff, 'manage_roles')): ?><a href="/staff/roles.php" class="<?= $activePage==='roles'?'active':'' ?>">Rollen</a><?php endif; ?>
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/availability.php" class="<?= $activePage==='availability'?'active':'' ?>">Status &amp; Schichten</a><?php endif; ?>
<?php if (has_permission($staff, 'manage_availability')): ?><a href="/staff/availability.php" class="<?= $activePage==='availability'?'active':'' ?>">Status &amp; Schichten</a><?php endif; ?>
<span class="nav-group-label">System</span>
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/security_check.php" class="<?= $activePage==='security_check'?'active':'' ?>">Sicherheitsprüfung</a><?php endif; ?>
<?php if (has_permission($staff, 'view_audit_log')): ?><a href="/staff/audit_log.php" class="<?= $activePage==='audit_log'?'active':'' ?>">Admin-Protokoll</a><?php endif; ?>
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/ticket_rules.php" class="<?= $activePage==='ticket_rules'?'active':'' ?>">Ticket-Regeln</a><?php endif; ?>
<?php if (has_permission($staff, 'manage_automation')): ?><a href="/staff/ticket_rules.php" class="<?= $activePage==='ticket_rules'?'active':'' ?>">Ticket-Regeln</a><?php endif; ?>
<?php if (has_permission($staff, 'manage_escalations')): ?><a href="/staff/escalation_rules.php" class="<?= $activePage==='escalation_rules'?'active':'' ?>">Eskalationsregeln</a><?php endif; ?>
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/recurring_tickets.php" class="<?= $activePage==='recurring'?'active':'' ?>">Wiederkehrende Tickets</a><?php endif; ?>
<?php if (has_permission($staff, 'manage_automation')): ?><a href="/staff/recurring_tickets.php" class="<?= $activePage==='recurring'?'active':'' ?>">Wiederkehrende Tickets</a><?php endif; ?>
<?php if (has_permission($staff, 'view_time_entries')): ?><a href="/staff/time_entries.php" class="<?= $activePage==='time_entries'?'active':'' ?>">Zeiterfassung</a><?php endif; ?>
<?php if (has_permission($staff, 'manage_business_hours')): ?><a href="/staff/business_hours.php" class="<?= $activePage==='business_hours'?'active':'' ?>">Geschäftszeiten</a><?php endif; ?>
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/settings.php" class="<?= $activePage==='settings'?'active':'' ?>">Einstellungen</a><?php endif; ?>
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/backup.php" class="<?= $activePage==='backup'?'active':'' ?>">Backup</a><?php endif; ?>
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/update.php" class="<?= $activePage==='update'?'active':'' ?>">System-Update</a><?php endif; ?>
<?php if (has_permission($staff, 'view_help')): ?><a href="/staff/help.php" class="<?= $activePage==='help'?'active':'' ?>">Hilfe</a><?php endif; ?>
</div>
<script>
(function () {
@@ -40,7 +40,7 @@
<?php foreach ($tickets as $t):
[$label, $color] = status_label($t['status']);
$isPaused = !empty($t['sla_paused_at']) && ticket_sla_is_paused_status((string) $t['status']);
$isOverdue = !empty($t['is_overdue']) || (!$isPaused && $t['resolution_due_at'] && strtotime($t['resolution_due_at']) < time() && !in_array($t['status'], ['resolved','closed']));
$isOverdue = !in_array($t['status'], ['resolved','closed']) && (!empty($t['is_overdue']) || (!$isPaused && $t['resolution_due_at'] && strtotime($t['resolution_due_at']) < time()));
?>
<tr class="<?= $isOverdue ? 'is-overdue' : '' ?>">
<td><input type="checkbox" class="ticket-check" name="ticket_ids[]" value="<?= $t['id'] ?>"></td>
@@ -61,7 +61,7 @@
<td><span class="badge" style="background:<?= $color ?>"><?= e($label) ?></span></td>
<td><?= $t['staff_name'] ? e($t['staff_name']) : '<span class="muted">–</span>' ?></td>
<td class="muted" style="white-space:nowrap" title="Eingegangen: <?= e(format_dt($t['created_at'] ?? null)) ?>"><?= e(format_dt($t['updated_at'] ?? $t['created_at'] ?? null)) ?></td>
<td class="muted"><?= $isPaused ? 'Pausiert' : time_until($t['resolution_due_at']) ?></td>
<td class="muted"><?= in_array($t['status'], ['resolved','closed'], true) ? '–' : ($isPaused ? 'Pausiert' : time_until($t['resolution_due_at'])) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
+98
View File
@@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
/**
* Prüft die vordefinierten Rollen: Sie werden bei Installation und Update angelegt (Migration 0076), nie überschrieben, nur bei Bedarf einzeln nachgelegt,
* und jede Vorlage gibt genau die Rechte, die ihre Beschreibung verspricht.
*/
putenv('DESKHUB_TESTING=1');
putenv('DESKHUB_SQLITE_PATH=:memory:');
require_once dirname(__DIR__) . '/includes/bootstrap.php';
require_once dirname(__DIR__) . '/database/migrate.php';
$failures = [];
$total = 0;
$check = function (bool $ok, string $what) use (&$failures, &$total): void {
$total++;
if (!$ok) $failures[] = $what;
};
$pdo = get_db();
$presets = permission_presets();
$seeded = array_filter($presets, fn($p) => $p[3]);
$all = array_keys(permission_labels());
$roleRow = fn(string $name) => $pdo->query("SELECT * FROM roles WHERE LOWER(name) = LOWER(" . $pdo->quote($name) . ")")->fetch();
$perms = fn(string $key): array => $presets[$key][2];
// ── Angelegt durch die Migration ────────────────────────────────────────────────
$check(count($seeded) >= 10, 'Es gibt mindestens 10 vordefinierte Rollen (' . count($seeded) . ')');
$check((int) $pdo->query("SELECT COUNT(*) FROM schema_migrations WHERE migration = 'php_0076_default_roles'")->fetchColumn() === 1, 'Migration 0076 ist eingetragen');
$names = array_map(fn($p) => mb_strtolower($p[0]), $presets);
$check(count($names) === count(array_unique($names)), 'Namen der Vorlagen sind eindeutig');
foreach ($seeded as $key => [$name, $desc, $p, $seed]) {
$r = $roleRow($name);
$check($r !== false && (int) $r['is_active'] === 1, "Rolle „{$name}“ ist angelegt und aktiv");
if (!$r) continue;
$stored = json_decode((string) $r['permissions'], true) ?: [];
$check($stored == $p && count($stored) === count($all), "Rolle „{$name}“ hat genau die Rechte der Vorlage (alle " . count($all) . ' Rechte gespeichert)');
$check(str_starts_with((string) $r['notes'], 'Vordefinierte Rolle') && str_contains((string) $r['notes'], $desc), "Rolle „{$name}“ hat die Beschreibung als Notiz");
}
$check($roleRow('Mitarbeiter') === false, '„Mitarbeiter“ ist nur eine Vorlage im Formular, keine Rolle (das sind die Standardrechte)');
$check((int) $pdo->query('SELECT COUNT(*) FROM roles')->fetchColumn() === count($seeded), 'Genau so viele Rollen wie vordefinierte');
// ── Sinnvolle Rechte je Vorlage ─────────────────────────────────────────────────
$check(count(array_filter($perms('co_admin'))) === count($all), 'Co-Administrator: alle Rechte');
$check(!$perms('support_1')['close_ticket'] && !$perms('support_1')['transfer_ticket'] && !$perms('support_1')['merge_split_ticket'] && !$perms('support_1')['manage_banlist'] && $perms('support_1')['reply_ticket'] && $perms('support_1')['assign_ticket'], 'Support 1st Level: antworten und zuweisen, aber nicht schließen, verschieben, zusammenführen, sperren');
$check($perms('support_2')['close_ticket'] && $perms('support_2')['manage_problems'] && !$perms('support_2')['delete_ticket'] && !$perms('support_2')['manage_staff'], 'Support 2nd Level: bearbeiten und Probleme, aber kein Löschen und keine Mitarbeiter');
$check($perms('it')['manage_email_addresses'] && $perms('it')['view_mail_queue'] && $perms('it')['view_login_guard'] && !$perms('it')['manage_staff'] && !$perms('it')['manage_roles'] && !$perms('it')['delete_ticket'], 'IT: Postfächer, Warteschlange, Anmelde-Wächter; keine Mitarbeiter/Rollen/Löschen');
$check($perms('teamleitung')['delete_ticket'] && $perms('teamleitung')['manage_teams'] && !$perms('teamleitung')['manage_staff'] && !$perms('teamleitung')['manage_departments'], 'Teamleitung: Löschen und Teams, aber nicht Mitarbeiter und Abteilungen');
$check($perms('support_manager')['manage_staff'] && $perms('support_manager')['manage_departments'] && !$perms('support_manager')['manage_roles'] && !$perms('support_manager')['manage_languages'], 'Support-Manager: Mitarbeiter und Einrichtung, aber nicht Rollen');
$check($perms('redaktion')['manage_kb'] && $perms('redaktion')['manage_site_pages'] && !$perms('redaktion')['close_ticket'] && !$perms('redaktion')['assign_ticket'], 'Redaktion: Inhalte pflegen, Tickets nicht verwalten');
$check(!$perms('auswertung')['reply_ticket'] && !$perms('auswertung')['post_note'] && $perms('auswertung')['view_reports'] && $perms('auswertung')['export_tickets'] && $perms('auswertung')['schedule_reports'], 'Auswertung: nur Zahlen, keine Tickets bearbeiten');
$check($perms('teamverwaltung')['manage_staff'] && $perms('teamverwaltung')['manage_availability'] && !$perms('teamverwaltung')['reply_ticket'] && !$perms('teamverwaltung')['manage_roles'], 'Teamverwaltung: Personal, aber keine Tickets und keine Rollen');
$check($perms('aushilfe')['reply_ticket'] && !$perms('aushilfe')['close_ticket'] && !$perms('aushilfe')['assign_ticket'] && !$perms('aushilfe')['create_ticket'] && !$perms('aushilfe')['fetch_mail'], 'Aushilfe: nur antworten und notieren');
$check(!$perms('nur_lesen')['reply_ticket'] && !$perms('nur_lesen')['post_note'] && !$perms('nur_lesen')['close_ticket'] && $perms('nur_lesen')['view_reports'], 'Nur lesen: ändert nichts');
// Wer darf Rollen verwalten, Einstellungen sind nie delegierbar: Nur Co-Administrator darf Rollen verwalten
$withRoles = array_keys(array_filter($presets, fn($p) => $p[2]['manage_roles']));
$check($withRoles === ['co_admin'], 'Nur der Co-Administrator darf Rollen verwalten (' . implode(',', $withRoles) . ')');
$withDelete = array_keys(array_filter($presets, fn($p) => $p[2]['delete_ticket']));
sort($withDelete);
$check($withDelete === ['co_admin', 'support_manager', 'teamleitung'], 'Löschen dürfen nur Co-Administrator, Support-Manager und Teamleitung (' . implode(',', $withDelete) . ')');
// ── Nie überschreiben ───────────────────────────────────────────────────────────
$check(permission_seed_default_roles($pdo) === [], 'Nochmal anlegen: nichts Neues (idempotent)');
$check(permission_missing_default_roles($pdo) === [], 'Es fehlt keine vordefinierte Rolle');
$pdo->prepare("UPDATE roles SET permissions = ?, notes = ?, name = ? WHERE LOWER(name) = LOWER(?)")->execute([json_encode(['reply_ticket' => true]), 'Meine Notiz', 'IT / TECHNIK', 'IT / Technik']);
$pdo->exec("DELETE FROM roles WHERE name = 'Aushilfe / Praktikant'");
$check(permission_missing_default_roles($pdo) === ['Aushilfe / Praktikant'], 'Eine gelöschte Rolle gilt als fehlend; eine umbenannte nur in der Schreibweise nicht');
$made = permission_seed_default_roles($pdo);
$check($made === ['Aushilfe / Praktikant'], 'Nur die fehlende Rolle wird nachgelegt (' . implode(',', $made) . ')');
$it = $pdo->query("SELECT permissions, notes, name FROM roles WHERE LOWER(name) = LOWER('IT / Technik')")->fetch();
$check($it && $it['notes'] === 'Meine Notiz' && json_decode($it['permissions'], true) === ['reply_ticket' => true], 'Eine geänderte Rolle bleibt unverändert');
$check((int) $pdo->query("SELECT COUNT(*) FROM roles WHERE LOWER(name) = LOWER('IT / Technik')")->fetchColumn() === 1, 'Keine Doppelte bei anderer Schreibweise');
// ── Update einer bestehenden Installation: eigene Rollen bleiben, Namen kollidieren nicht ───────
$pdo->exec("DELETE FROM roles");
$pdo->prepare('INSERT INTO roles (name, permissions, is_active, created_at, updated_at) VALUES (?,?,1,?,?)')->execute(['Teamleitung', json_encode(['reply_ticket' => true]), date('Y-m-d H:i:s'), date('Y-m-d H:i:s')]);
$pdo->exec("DELETE FROM schema_migrations WHERE migration = 'php_0076_default_roles'");
$res = run_migrations($pdo, false);
$check(($res['errors'] ?? []) === [], 'Migration läuft ohne Fehler: ' . json_encode($res['errors'] ?? []));
$tl = $pdo->query("SELECT permissions FROM roles WHERE name = 'Teamleitung'")->fetchAll(PDO::FETCH_COLUMN);
$check(count($tl) === 1 && json_decode($tl[0], true) === ['reply_ticket' => true], 'Update: eine schon vorhandene „Teamleitung“ bleibt unverändert und wird nicht doppelt angelegt');
$check((int) $pdo->query('SELECT COUNT(*) FROM roles')->fetchColumn() === count($seeded), 'Update: alle anderen vordefinierten Rollen kommen dazu');
$check((int) $pdo->query("SELECT COUNT(*) FROM schema_migrations WHERE migration = 'php_0076_default_roles'")->fetchColumn() === 1, 'Update: Migration ist wieder eingetragen');
$before = (int) $pdo->query('SELECT COUNT(*) FROM roles')->fetchColumn();
run_migrations($pdo, false);
$check((int) $pdo->query('SELECT COUNT(*) FROM roles')->fetchColumn() === $before, 'Die Migration läuft nur einmal');
// Rolle wirkt wirklich: Mitarbeiter mit „Support 1st Level“
$pdo->exec('DELETE FROM staff');
$dept = (int) $pdo->query('SELECT id FROM departments ORDER BY id LIMIT 1')->fetchColumn();
$rid = (int) $pdo->query("SELECT id FROM roles WHERE name = 'Support 1st Level'")->fetchColumn();
$pdo->prepare('INSERT INTO staff (name, email, password_hash, role, role_id, department_id, is_active, created_at) VALUES (?,?,?,?,?,?,1,?)')->execute(['Neu', 'neu@test.de', 'x', 'agent', $rid, $dept, date('Y-m-d H:i:s')]);
$st = $pdo->query('SELECT * FROM staff WHERE email = \'neu@test.de\'')->fetch();
$check(has_permission($st, 'reply_ticket') && has_permission($st, 'assign_ticket') && !has_permission($st, 'close_ticket') && !has_permission($st, 'manage_banlist') && !has_permission($st, 'manage_custom_fields'), 'Ein Mitarbeiter mit „Support 1st Level“ hat genau diese Rechte');
echo $failures ? "FEHLGESCHLAGEN:\n - " . implode("\n - ", $failures) . "\n" : '';
echo "Vordefinierte-Rollen-Test: " . ($total - count($failures)) . " von {$total} Prüfungen in Ordnung.\n";
exit($failures ? 1 : 0);
+1
View File
@@ -53,6 +53,7 @@ try {
putenv('DESKHUB_SQLITE_PATH=:memory:');
require_once $root . '/includes/bootstrap.php';
$pdo = get_db();
set_setting('sla_notify_mode', 'assignee'); // diese Prüfungen betreffen den Modus „Bearbeiter bzw. Abteilung“ (Standard ist „nur Administratoren“)
$now = db_now();
// Dienstkonto prüfen
+67
View File
@@ -0,0 +1,67 @@
<?php
/** Nachgebauter Authenticator für die Passkey-Tests (ES256 und RS256). Erwartet $GLOBALS['fa_rp'] (Domain) und $GLOBALS['fa_origin']. */
function t_cbor($v): string
{
if (is_int($v)) return $v >= 0 ? t_cbor_head(0, $v) : t_cbor_head(1, -1 - $v);
if (is_string($v) && !empty($v) && str_starts_with($v, "\0BIN")) { $b = substr($v, 4); return t_cbor_head(2, strlen($b)) . $b; }
if (is_string($v)) return t_cbor_head(3, strlen($v)) . $v;
if (is_array($v)) { $o = t_cbor_head(5, count($v)); foreach ($v as $k => $x) $o .= t_cbor($k) . t_cbor($x); return $o; }
throw new RuntimeException('x');
}
function t_cbor_head(int $m, int $n): string
{
if ($n < 24) return chr(($m << 5) | $n);
if ($n < 256) return chr(($m << 5) | 24) . chr($n);
if ($n < 65536) return chr(($m << 5) | 25) . pack('n', $n);
return chr(($m << 5) | 26) . pack('N', $n);
}
class FakeKey
{
public $res; public string $kind; public string $credId; public int $count = 0;
public function __construct(string $kind)
{
$this->kind = $kind;
static $cnf = null;
if ($cnf === null) { $cnf = sys_get_temp_dir() . '/deskhub-pk-' . bin2hex(random_bytes(4)) . '.cnf'; file_put_contents($cnf, "[req]
distinguished_name=d
[d]
"); }
$this->res = $kind === 'ec' ? openssl_pkey_new(['config' => $cnf, 'private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_EC, 'curve_name' => 'prime256v1'])
: openssl_pkey_new(['config' => $cnf, 'private_key_type' => OPENSSL_KEYTYPE_RSA, 'private_key_bits' => 2048]);
$this->credId = random_bytes(32);
}
public function cose(): string
{
$d = openssl_pkey_get_details($this->res);
if ($this->kind === 'ec') return t_cbor([1 => 2, 3 => -7, -1 => 1, -2 => "\0BIN" . $d['ec']['x'], -3 => "\0BIN" . $d['ec']['y']]);
return t_cbor([1 => 3, 3 => -257, -1 => "\0BIN" . $d['rsa']['n'], -2 => "\0BIN" . $d['rsa']['e']]);
}
}
function t_client(string $type, string $challenge, ?string $origin = null): string
{
return json_encode(['type' => $type, 'challenge' => $challenge, 'origin' => $origin ?? $GLOBALS['fa_origin'], 'crossOrigin' => false]);
}
function t_register(FakeKey $k, array $opt, array $o = []): array
{
$rpId = $o['rpId'] ?? $GLOBALS['fa_rp'];
$flags = $o['flags'] ?? 0x45;
$ad = hash('sha256', $rpId, true) . chr($flags) . pack('N', $k->count) . str_repeat("\0", 16) . pack('n', strlen($k->credId)) . $k->credId . $k->cose();
$att = t_cbor(['fmt' => 'none', 'attStmt' => [], 'authData' => "\0BIN" . $ad]);
$cd = t_client($o['type'] ?? 'webauthn.create', $o['challenge'] ?? $opt['challenge'], $o['origin'] ?? null);
return ['id' => webauthn_b64u_encode($k->credId), 'response' => ['clientDataJSON' => webauthn_b64u_encode($cd), 'attestationObject' => webauthn_b64u_encode($att)]];
}
function t_login(FakeKey $k, array $opt, int $staffId, array $o = []): array
{
$flags = $o['flags'] ?? 0x05;
$k->count = $o['count'] ?? $k->count + 1;
$ad = hash('sha256', $o['rpId'] ?? $GLOBALS['fa_rp'], true) . chr($flags) . pack('N', $k->count);
$cd = t_client($o['type'] ?? 'webauthn.get', $o['challenge'] ?? $opt['challenge'], $o['origin'] ?? null);
$sig = '';
openssl_sign($ad . hash('sha256', $cd, true), $sig, $o['signWith'] ?? $k->res, OPENSSL_ALGO_SHA256);
return ['id' => webauthn_b64u_encode($k->credId), 'response' => [
'clientDataJSON' => webauthn_b64u_encode($cd), 'authenticatorData' => webauthn_b64u_encode($ad), 'signature' => webauthn_b64u_encode($sig),
'userHandle' => $o['userHandle'] ?? webauthn_b64u_encode(webauthn_user_handle($staffId)),
]];
}
+162
View File
@@ -0,0 +1,162 @@
<?php
declare(strict_types=1);
/**
* Prüft die Hilfe-Seite für Administratoren (staff/help.php): Zugriff nur für echte Administratoren, Handbuch aus dem Programmpaket herunterladen,
* kein Upload, Prüfsumme nach einer neuen Fassung.
*/
if (!extension_loaded('pdo_sqlite') || !function_exists('curl_init')) { echo "Abmelde-Web-Test übersprungen: pdo_sqlite oder curl fehlt.\n"; exit(0); }
date_default_timezone_set('Europe/Berlin');
$root = dirname(__DIR__);
$work = sys_get_temp_dir() . '/deskhub-help-' . bin2hex(random_bytes(4));
$tmp = $work . '/inst';
mkdir($tmp, 0755, true);
$webPort = random_int(36000, 36499);
$skip = '#^(config/config\.php|database/[^/]+\.sqlite[^/]*|backups|uploads|tests|plugins/[^/]+|\.git)(/|$)#';
foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::SELF_FIRST) as $f) {
$rel = str_replace(chr(92), '/', substr($f->getPathname(), strlen($root) + 1));
if (preg_match($skip, $rel) && !preg_match('#^(backups|uploads|plugins)/(\.htaccess|README\.md)$#', $rel)) continue;
if ($f->isDir()) { @mkdir($tmp . '/' . $rel, 0755, true); continue; }
@mkdir(dirname($tmp . '/' . $rel), 0755, true);
copy($f->getPathname(), $tmp . '/' . $rel);
}
@mkdir($tmp . '/uploads');
@mkdir($tmp . '/config');
file_put_contents($tmp . '/config/config.php', '<?php return ' . var_export([
'app_key' => bin2hex(random_bytes(32)),
'db' => ['driver' => 'sqlite', 'sqlite_path' => $tmp . '/database/deskhub.sqlite'],
'site' => ['name' => 'Test', 'url' => 'http://127.0.0.1', 'admin_email' => 'a@test.de', 'timezone' => 'Europe/Berlin'],
'uploads' => ['max_size_bytes' => 10485760, 'allowed_extensions' => ['png'], 'path' => $tmp . '/uploads'],
], true) . ';');
putenv('DESKHUB_CONFIG_JSON');
putenv('DESKHUB_SQLITE_PATH');
putenv('DESKHUB_TESTING');
putenv('DESKHUB_MANUAL_PATH=' . $work . '/manual.pdf');
putenv('DESKHUB_API_ALLOW_INSECURE=1');
require_once $root . '/database/sqlite_schema.php';
require_once $root . '/database/migrate.php';
$db = new PDO('sqlite:' . $tmp . '/database/deskhub.sqlite');
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$db->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
$db->exec('PRAGMA foreign_keys = ON');
$db->exec(convert_schema_to_sqlite((string) file_get_contents($root . '/database/schema.sql')));
run_migrations($db, true);
$now = date('Y-m-d H:i:s');
$dept = (int) $db->query('SELECT id FROM departments ORDER BY id LIMIT 1')->fetchColumn();
$db->exec('DELETE FROM staff');
$ins = $db->prepare('INSERT INTO staff (name, email, password_hash, role, department_id, is_active, created_at) VALUES (?,?,?,?,?,1,?)');
foreach ([['Chef', 'chef@test.de', 'admin'], ['Eva', 'eva@test.de', 'agent'], ['Max', 'max@test.de', 'agent']] as [$n, $e, $r]) {
$ins->execute([$n, $e, password_hash('Sehr-Sicher-1234', PASSWORD_DEFAULT), $r, $dept, $now]);
}
$evaId = (int) $db->query("SELECT id FROM staff WHERE email = 'eva@test.de'")->fetchColumn();
$db = null;
$failures = [];
$total = 0;
$check = function (bool $ok, string $what) use (&$failures, &$total): void { $total++; if (!$ok) $failures[] = $what; };
$webProc = proc_open([PHP_BINARY, '-S', "127.0.0.1:$webPort", '-t', $tmp], [['pipe', 'r'], ['file', $work . '/web.out', 'w'], ['file', $work . '/web.out', 'a']], $wp, $tmp);
try {
for ($i = 0; $i < 50; $i++) { if (@fsockopen('127.0.0.1', $webPort)) break; usleep(100000); }
$pdo = function () use ($tmp): PDO {
$p = new PDO('sqlite:' . $tmp . '/database/deskhub.sqlite');
$p->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$p->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
return $p;
};
$call = function (string $method, string $path, array $form, string $jar, ?string $bearer = null, ?array $json = null) use ($webPort): array {
$ch = curl_init("http://127.0.0.1:$webPort$path");
$h = ['Sec-Fetch-Mode: navigate'];
if ($json !== null) $h[] = 'Content-Type: application/json';
if ($bearer) $h[] = 'Authorization: Bearer ' . $bearer;
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => false, CURLOPT_TIMEOUT => 60, CURLOPT_HEADER => true, CURLOPT_HTTPHEADER => $h]);
if ($jar !== '') { curl_setopt($ch, CURLOPT_COOKIEJAR, $jar); curl_setopt($ch, CURLOPT_COOKIEFILE, $jar); }
if ($method === 'POST') { curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, $json !== null ? json_encode($json) : http_build_query($form)); }
$raw = (string) curl_exec($ch);
$s = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$hs = (int) curl_getinfo($ch, CURLINFO_HEADER_SIZE);
curl_close($ch);
return [$s, substr($raw, $hs), substr($raw, 0, $hs)];
};
$login = function (string $email) use ($call): string {
$jar = tempnam(sys_get_temp_dir(), 'sow');
[, $lp] = $call('GET', '/staff/login.php', [], $jar);
preg_match('/name="csrf_token" value="([a-f0-9]+)"/', $lp, $m);
$call('POST', '/staff/login.php', ['csrf_token' => $m[1] ?? '', 'email' => $email, 'password' => 'Sehr-Sicher-1234'], $jar);
return $jar;
};
$csrf = function (string $path, string $jar) use ($call): string {
[, $p] = $call('GET', $path, [], $jar);
preg_match('/name="csrf_token" value="([a-f0-9]+)"/', $p, $mm);
return $mm[1] ?? '';
};
$loggedIn = function (string $jar) use ($call): bool {
[$s, $b] = $call('GET', '/staff/profile.php?tab=security', [], $jar);
return $s === 200 && str_contains($b, 'Von allen Geräten abmelden');
};
$apiToken = function (string $email) use ($call): string {
[, $b] = $call('POST', '/api/staff/v1/login.php', [], '', null, ['email' => $email, 'password' => 'Sehr-Sicher-1234', 'deviceName' => 'Test-Gerät']);
return (string) (json_decode($b, true)['token'] ?? '');
};
$apiOk = fn(string $tok) => $call('GET', '/api/staff/v1/me.php', [], '', $tok)[0];
$file = $work . '/manual.pdf'; // über DESKHUB_MANUAL_PATH eingestellt
$has = function () use ($file): bool { clearstatcache(true); return is_file($file); };
$jarChef = $login('chef@test.de');
$jarEva = $login('eva@test.de');
// Das Handbuch gehört zum Programmpaket
$bundled = $root . '/docs/DeskHub-Handbuch.pdf';
$check(is_file($bundled) && file_get_contents($bundled, false, null, 0, 5) === '%PDF-' && filesize($bundled) > 100000, 'Das Handbuch liegt im Programmpaket (docs/DeskHub-Handbuch.pdf)');
// Zugriff: nur echte Administratoren
[$s, $page] = $call('GET', '/staff/help.php', [], $jarChef);
$check($s === 200 && str_contains($page, 'Hilfe für Administratoren') && str_contains($page, 'nicht vorhanden') && str_contains($page, 'Erste Schritte'), 'Administrator sieht die Hilfe-Seite (hier noch ohne Handbuch)');
$check(!str_contains($page, 'Hochladen') && !str_contains($page, 'type="file"') && !str_contains($page, 'Handbuch entfernen'), 'Es gibt keinen Upload und kein Entfernen');
$check(str_contains($page, 'href="/staff/help.php"'), 'Menüpunkt „Hilfe“ im Adminportal');
[, $evaPage] = $call('GET', '/staff/profile.php', [], $jarEva);
$check(!str_contains($evaPage, 'href="/staff/help.php"'), 'Normaler Mitarbeiter sieht den Menüpunkt nicht');
[$s, $b] = $call('GET', '/staff/help.php', [], $jarEva);
$check($s !== 200 || !str_contains($b, 'Hilfe für Administratoren'), 'Normaler Mitarbeiter kommt nicht auf die Seite (HTTP ' . $s . ')');
[$s, $b] = $call('GET', '/staff/help.php', [], '');
$check($s === 302 || !str_contains($b, 'Hilfe für Administratoren'), 'Ohne Anmeldung kommt man nicht auf die Seite');
[$s] = $call('GET', '/staff/help.php?download=1', [], $jarChef);
$check($s === 404, 'Download ohne Datei: 404');
// Ein Upload-Versuch wird ignoriert
$t = $csrf('/staff/help.php', $jarChef);
$call('POST', '/staff/help.php', ['csrf_token' => $t, 'action' => 'upload', 'delete' => '1'], $jarChef);
$check(!$has(), 'POST-Aktionen (Upload) bewirken nichts');
// Handbuch ist da (mit dem Update gekommen)
file_put_contents($file, "%PDF-1.4\n" . str_repeat("% Beispiel\n", 500));
$sha = hash_file('sha256', $file);
[$s, $page] = $call('GET', '/staff/help.php', [], $jarChef);
$check($s === 200 && str_contains($page, 'Handbuch herunterladen') && str_contains($page, $sha) && str_contains($page, 'Zu DeskHub '), 'Seite zeigt Download, Version und Prüfsumme');
[$s, $body, $hdr] = $call('GET', '/staff/help.php?download=1', [], $jarChef);
$check($s === 200 && $body === file_get_contents($file) && stripos($hdr, 'application/pdf') !== false && stripos($hdr, 'attachment; filename="DeskHub-Handbuch.pdf"') !== false, 'Administrator lädt die PDF herunter (unverändert, als Anhang)');
[$s, $body] = $call('GET', '/staff/help.php?download=1', [], $jarEva);
$check($body !== file_get_contents($file), 'Normaler Mitarbeiter bekommt die PDF nicht');
[$s, $body] = $call('GET', '/staff/help.php?download=1', [], '');
$check($body !== file_get_contents($file), 'Ohne Anmeldung gibt es die PDF nicht');
// Neue Fassung mit dem nächsten Update: Prüfsumme wird neu berechnet
file_put_contents($file, "%PDF-1.5\n" . str_repeat("% Neue Fassung\n", 700));
touch($file, time() + 5);
[, $page] = $call('GET', '/staff/help.php', [], $jarChef);
$check(str_contains($page, hash_file('sha256', $file)) && !str_contains($page, $sha), 'Nach einer neuen Fassung stimmt die Prüfsumme');
foreach ([$jarChef, $jarEva] as $j) @unlink($j);
} finally {
if (is_resource($webProc)) { proc_terminate($webProc); proc_close($webProc); }
$rm = function (string $d) use (&$rm): void { foreach (glob($d . '/{,.}*', GLOB_BRACE) ?: [] as $e) { $b = basename($e); if ($b === '.' || $b === '..') continue; is_dir($e) ? $rm($e) : @unlink($e); } @rmdir($d); };
$rm($work);
}
if ($failures) {
fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n");
exit(1);
}
echo "Help page web checks passed ({$total} Prüfungen).\n";
+54
View File
@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
/**
* Prüft, dass das englische Sprachpaket (neueste Version in Plugins/plugins/lang-en-*.zip) alle Texte des Rechte-Katalogs und der Rollen-Vorlagen enthält
* (Gruppen, Rechte, Hinweise, Rollen-Namen und -Beschreibungen) und dass die Übersetzungen nicht leer oder unverändert sind.
*/
putenv('DESKHUB_TESTING=1');
putenv('DESKHUB_SQLITE_PATH=:memory:');
require_once dirname(__DIR__) . '/includes/bootstrap.php';
$dir = getenv('DESKHUB_PLUGIN_SRC') ?: dirname(__DIR__, 2) . '/Plugins';
$zips = glob($dir . '/plugins/lang-en-*.zip') ?: [];
if (!$zips) { echo "Test übersprungen: kein lang-en-Paket gefunden.\n"; exit(0); }
usort($zips, fn($a, $b) => version_compare(preg_replace('/^.*lang-en-(.*)\.zip$/', '$1', $a), preg_replace('/^.*lang-en-(.*)\.zip$/', '$1', $b)));
$zip = new ZipArchive();
$zip->open(end($zips));
$strings = null;
for ($i = 0; $i < $zip->numFiles; $i++) if (str_ends_with($zip->getNameIndex($i), 'strings.json')) $strings = json_decode($zip->getFromIndex($i), true);
$failures = [];
$total = 0;
$check = function (bool $ok, string $what) use (&$failures, &$total): void { $total++; if (!$ok) $failures[] = $what; };
$check(is_array($strings) && count($strings) > 3000, 'Sprachpaket gelesen (' . basename(end($zips)) . ', ' . count((array) $strings) . ' Einträge)');
$texts = [];
foreach (permission_catalog() as $g) {
$texts[] = $g['title'];
foreach ($g['items'] as $i) { $texts[] = $i['label']; $texts[] = $i['hint']; }
}
foreach (permission_presets() as $p) { $texts[] = $p[0]; $texts[] = $p[1]; }
$texts = array_unique($texts);
$missing = [];
$same = [];
foreach ($texts as $t) {
if (!isset($strings[$t]) || trim((string) $strings[$t]) === '') { $missing[] = $t; continue; }
// „unverändert“ ist nur für Wörter erlaubt, die in beiden Sprachen gleich sind
if ($strings[$t] === $t && mb_strlen($t) > 14) $same[] = $t;
}
$check($missing === [], 'Alle ' . count($texts) . ' Texte des Rechte-Katalogs und der Rollen-Vorlagen sind übersetzt; es fehlen: ' . implode(' | ', array_slice($missing, 0, 5)));
$check($same === [], 'Keine unübersetzten Texte (Übersetzung = Original): ' . implode(' | ', array_slice($same, 0, 3)));
$german = array_filter($texts, fn($t) => isset($strings[$t]) && preg_match('/\b(und|oder|der|die|das|für|nicht|mit)\b/u', $strings[$t]));
$check($german === [], 'Die englischen Texte enthalten keine deutschen Füllwörter: ' . implode(' | ', array_slice(array_map(fn($t) => $strings[$t], $german), 0, 3)));
foreach (['Fehlende Rollen anlegen', 'Rechte-Übersicht: Wer darf was?', 'Vorlage laden:', 'Hilfe für Administratoren', 'Passkeys erlauben', 'vordefiniert', 'Wenn etwas nicht funktioniert'] as $k) {
$check(!empty($strings[$k]), "Oberflächentext „{$k}“ ist übersetzt");
}
// Platzhalter stimmen überein
$bad = [];
foreach ($strings as $k => $v) if ($k !== '_info' && is_string($k) && is_string($v) && substr_count($k, '{') !== substr_count($v, '{')) $bad[] = $k;
$check($bad === [], 'Platzhalter in allen Einträgen gleich (' . count($bad) . ' Abweichungen)');
echo $failures ? "FEHLGESCHLAGEN:\n - " . implode("\n - ", $failures) . "\n" : '';
echo "Sprachpaket-Rechte-Test: " . ($total - count($failures)) . " von {$total} Prüfungen in Ordnung.\n";
exit($failures ? 1 : 0);
+200
View File
@@ -0,0 +1,200 @@
<?php
declare(strict_types=1);
/**
* Prüft die Anmeldesperre über die echte Weboberfläche: Sperre nach zu vielen Fehlversuchen, bekannte Geräte bleiben trotz Sperre anmeldbar,
* Administrator hebt die Sperre auf, „Passwort vergessen“ hebt sie auf, App-Anmeldung wird mit aufgehoben.
*/
if (!extension_loaded('pdo_sqlite') || !function_exists('curl_init')) { echo "Anmeldesperre-Test übersprungen: pdo_sqlite oder curl fehlt.\n"; exit(0); }
date_default_timezone_set('Europe/Berlin');
$root = dirname(__DIR__);
$work = sys_get_temp_dir() . '/deskhub-lock-' . bin2hex(random_bytes(4));
$tmp = $work . '/inst';
mkdir($tmp, 0755, true);
$webPort = random_int(35000, 35499);
$skip = '#^(config/config\.php|database/[^/]+\.sqlite[^/]*|backups|uploads|tests|plugins/[^/]+|\.git)(/|$)#';
foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::SELF_FIRST) as $f) {
$rel = str_replace(chr(92), '/', substr($f->getPathname(), strlen($root) + 1));
if (preg_match($skip, $rel) && !preg_match('#^(backups|uploads|plugins)/(\.htaccess|README\.md)$#', $rel)) continue;
if ($f->isDir()) { @mkdir($tmp . '/' . $rel, 0755, true); continue; }
@mkdir(dirname($tmp . '/' . $rel), 0755, true);
copy($f->getPathname(), $tmp . '/' . $rel);
}
@mkdir($tmp . '/uploads');
@mkdir($tmp . '/config');
file_put_contents($tmp . '/config/config.php', '<?php return ' . var_export([
'app_key' => bin2hex(random_bytes(32)),
'db' => ['driver' => 'sqlite', 'sqlite_path' => $tmp . '/database/deskhub.sqlite'],
'site' => ['name' => 'Test', 'url' => 'http://127.0.0.1', 'admin_email' => 'a@test.de', 'timezone' => 'Europe/Berlin'],
'uploads' => ['max_size_bytes' => 10485760, 'allowed_extensions' => ['png'], 'path' => $tmp . '/uploads'],
], true) . ';');
putenv('DESKHUB_CONFIG_JSON');
putenv('DESKHUB_SQLITE_PATH');
putenv('DESKHUB_TESTING');
putenv('DESKHUB_API_ALLOW_INSECURE=1');
require_once $root . '/database/sqlite_schema.php';
require_once $root . '/database/migrate.php';
$db = new PDO('sqlite:' . $tmp . '/database/deskhub.sqlite');
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$db->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
$db->exec('PRAGMA foreign_keys = ON');
$db->exec(convert_schema_to_sqlite((string) file_get_contents($root . '/database/schema.sql')));
run_migrations($db, true);
$now = date('Y-m-d H:i:s');
$dept = (int) $db->query('SELECT id FROM departments ORDER BY id LIMIT 1')->fetchColumn();
$db->exec('DELETE FROM staff');
$ins = $db->prepare('INSERT INTO staff (name, email, password_hash, role, department_id, is_active, created_at) VALUES (?,?,?,?,?,1,?)');
foreach ([['Chef', 'chef@test.de', 'admin'], ['Eva', 'eva@test.de', 'agent']] as [$n, $e, $r]) {
$ins->execute([$n, $e, password_hash('Sehr-Sicher-1234', PASSWORD_DEFAULT), $r, $dept, $now]);
}
$evaId = (int) $db->query("SELECT id FROM staff WHERE email = 'eva@test.de'")->fetchColumn();
$db = null;
$failures = [];
$total = 0;
$check = function (bool $ok, string $what) use (&$failures, &$total): void { $total++; if (!$ok) $failures[] = $what; };
$webProc = proc_open([PHP_BINARY, '-S', "127.0.0.1:$webPort", '-t', $tmp], [['pipe', 'r'], ['file', $work . '/web.out', 'w'], ['file', $work . '/web.out', 'a']], $wp, $tmp);
try {
for ($i = 0; $i < 50; $i++) { if (@fsockopen('127.0.0.1', $webPort)) break; usleep(100000); }
$pdo = function () use ($tmp): PDO {
$p = new PDO('sqlite:' . $tmp . '/database/deskhub.sqlite');
$p->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$p->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
return $p;
};
$call = function (string $method, string $path, array $form, string $jar, ?array $json = null) use ($webPort): array {
$ch = curl_init("http://127.0.0.1:$webPort$path");
$h = ['Sec-Fetch-Mode: navigate'];
if ($json !== null) $h[] = 'Content-Type: application/json';
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => false, CURLOPT_TIMEOUT => 60, CURLOPT_HEADER => true, CURLOPT_HTTPHEADER => $h]);
if ($jar !== '') { curl_setopt($ch, CURLOPT_COOKIEJAR, $jar); curl_setopt($ch, CURLOPT_COOKIEFILE, $jar); }
if ($method === 'POST') { curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, $json !== null ? json_encode($json) : http_build_query($form)); }
$raw = (string) curl_exec($ch);
$s = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$hs = (int) curl_getinfo($ch, CURLINFO_HEADER_SIZE);
curl_close($ch);
return [$s, substr($raw, $hs), substr($raw, 0, $hs)];
};
$newJar = fn() => tempnam(sys_get_temp_dir(), 'lck');
// Eine Anmeldung: gibt [HTTP-Status, Seitentext] zurück
$attempt = function (string $jar, string $email, string $pass) use ($call): array {
[, $lp] = $call('GET', '/staff/login.php', [], $jar);
preg_match('/name="csrf_token" value="([a-f0-9]+)"/', $lp, $m);
[$s, $b] = $call('POST', '/staff/login.php', ['csrf_token' => $m[1] ?? '', 'email' => $email, 'password' => $pass], $jar);
return [$s, $b];
};
$csrf = function (string $path, string $jar) use ($call): string {
[, $p] = $call('GET', $path, [], $jar);
preg_match('/name="csrf_token" value="([a-f0-9]+)"/', $p, $mm);
return $mm[1] ?? '';
};
// Die Prüfungen laufen alle von derselben Adresse: Der Zähler je IP-Adresse wird zwischendurch geleert, damit er die Kontosperre nicht verdeckt
$freeIp = fn() => $pdo()->exec("DELETE FROM rate_limit_log WHERE bucket = 'staff_login_attempt'");
$locked = fn(string $b) => str_contains($b, 'Zu viele Anmeldeversuche für dieses Konto');
$GOOD = 'Sehr-Sicher-1234';
// Eva meldet sich einmal an ihrem eigenen Browser an: Das Gerät wird gemerkt
$jarEva = $newJar();
[$s] = $attempt($jarEva, 'eva@test.de', $GOOD);
$check($s === 302, 'Eva meldet sich an (HTTP ' . $s . ')');
$cookie = '';
foreach (file($jarEva) ?: [] as $line) { if (preg_match('/\tdh_device\t([a-f0-9]{32})\s*$/', $line, $m)) $cookie = $m[1]; }
$check($cookie !== '', 'das Gerät wurde mit einem Cookie gemerkt');
// Ein Browser, der nur dieses Cookie hat (Eva ist dort abgemeldet)
$knownJar = $newJar();
file_put_contents($knownJar, "# Netscape HTTP Cookie File\n#HttpOnly_127.0.0.1\tFALSE\t/\tFALSE\t" . (time() + 86400) . "\tdh_device\t{$cookie}\n");
// Ein Angreifer ohne Cookie: fünf falsche Versuche sperren das Konto
$attacker = $newJar();
$msgs = [];
for ($i = 1; $i <= 5; $i++) { [, $b] = $attempt($attacker, 'eva@test.de', 'falsch-' . $i); $msgs[] = $locked($b); }
$check(!in_array(true, $msgs, true), 'die ersten fünf Fehlversuche zeigen noch „falsch“, keine Sperre');
[, $b] = $attempt($attacker, 'eva@test.de', $GOOD);
$check($locked($b), 'danach ist das Konto für den Angreifer gesperrt, auch mit dem richtigen Passwort');
$other = $newJar();
[, $b] = $attempt($other, 'eva@test.de', $GOOD);
$check($locked($b), 'ein weiterer Browser ohne gemerktes Gerät ist ebenfalls ausgesperrt');
$check((int) $pdo()->query("SELECT COUNT(*) FROM login_events WHERE event = 'locked' AND staff_id = {$evaId}")->fetchColumn() === 1, 'die Sperre steht einmal im Anmelde-Wächter');
// Evas bekanntes Gerät darf sich trotz Sperre anmelden
$freeIp();
[$s, $b] = $attempt($knownJar, 'eva@test.de', 'falsch-bei-bekanntem-geraet');
$check(!$locked($b) && str_contains($b, 'falsch'), 'bekanntes Gerät mit falschem Passwort: normale Fehlermeldung, keine Sperrmeldung');
[$s] = $attempt($knownJar, 'eva@test.de', $GOOD);
$check($s === 302, 'bekanntes Gerät mit richtigem Passwort: Anmeldung klappt trotz Sperre (HTTP ' . $s . ')');
// Die erfolgreiche Anmeldung setzt den Zähler zurück: auch der Angreifer ist nicht mehr gesperrt (richtiges Passwort kennt er nicht)
[, $b] = $attempt($attacker, 'eva@test.de', 'falsch-neu');
$check(!$locked($b), 'nach der erfolgreichen Anmeldung des echten Mitarbeiters beginnt die Zählung neu');
// Wieder sperren und die Verwaltung testen
$freeIp();
foreach (range(1, 6) as $i) $attempt($attacker, 'eva@test.de', 'nochmal-' . $i);
$freeIp();
[, $b] = $attempt($other, 'eva@test.de', $GOOD);
$check($locked($b), 'wieder gesperrt');
$jarChef = $newJar();
$attempt($jarChef, 'chef@test.de', $GOOD);
[, $page] = $call('GET', '/staff/staff_manage.php', [], $jarChef);
$check(str_contains($page, 'Anmeldung zeitweise gesperrt') && str_contains($page, 'Sperre aufheben'), 'Mitarbeiterverwaltung zeigt „Anmeldung zeitweise gesperrt“ und „Sperre aufheben“');
$t = $csrf('/staff/staff_manage.php', $jarChef);
[$s] = $call('POST', '/staff/staff_manage.php', ['csrf_token' => $t, 'action' => 'unlock_login', 'id' => $evaId], $jarChef);
$check($s === 302, 'Administrator hebt die Sperre auf (HTTP ' . $s . ')');
[, $page] = $call('GET', '/staff/staff_manage.php', [], $jarChef);
$check(!str_contains($page, 'Anmeldung zeitweise gesperrt'), 'danach ist der Hinweis weg');
$freeIp();
[$s] = $attempt($other, 'eva@test.de', $GOOD);
$check($s === 302, 'Eva kann sich sofort wieder anmelden (HTTP ' . $s . ')');
$check((int) $pdo()->query("SELECT COUNT(*) FROM login_events WHERE event = 'unlocked' AND staff_id = {$evaId}")->fetchColumn() === 1, 'Protokoll: „Sperre aufgehoben“');
// Normaler Mitarbeiter darf nicht
$freeIp();
foreach (range(1, 6) as $i) $attempt($attacker, 'eva@test.de', 'x' . $i);
$freeIp();
$jarEva2 = $newJar();
[, $b] = $attempt($jarEva2, 'eva@test.de', $GOOD);
$check($locked($b), 'wieder gesperrt (für den Test „Passwort vergessen“)');
// „Passwort vergessen“ hebt die Sperre auf
$token = bin2hex(random_bytes(48));
$pdo()->prepare("INSERT INTO password_resets (account_type, email, token_hash, expires_at, created_at) VALUES ('staff', ?, ?, ?, ?)")
->execute(['eva@test.de', hash('sha256', $token), date('Y-m-d H:i:s', time() + 3600), date('Y-m-d H:i:s')]);
$resetJar = $newJar();
[, $p] = $call('GET', '/staff/reset_password.php?token=' . $token, [], $resetJar);
preg_match('/name="csrf_token" value="([a-f0-9]+)"/', $p, $m);
$newPass = 'Neues-Passwort-5678!';
[$s, $b] = $call('POST', '/staff/reset_password.php', ['csrf_token' => $m[1] ?? '', 'token' => $token, 'password' => $newPass, 'password2' => $newPass], $resetJar);
$check(str_contains($b, 'Ihr Passwort wurde geändert'), 'Passwort per Reset-Link geändert');
$freeIp();
[$s] = $attempt($jarEva2, 'eva@test.de', $newPass);
$check($s === 302, 'danach ist die Sperre aufgehoben, Anmeldung mit dem neuen Passwort klappt (HTTP ' . $s . ')');
$why = (string) $pdo()->query("SELECT detail FROM login_events WHERE event = 'unlocked' ORDER BY id DESC LIMIT 1")->fetchColumn();
$check(str_contains($why, 'Reset-Link'), 'Protokoll nennt den Reset-Link als Grund');
// IP-Sperre bleibt: viele Versuche von einer Adresse
$freeIp();
$ipJar = $newJar();
$ipBlocked = false;
foreach (range(1, 12) as $i) { [, $b] = $attempt($ipJar, 'niemand' . $i . '@test.de', 'x'); if (str_contains($b, 'Zu viele Anmeldeversuche. Bitte versuchen')) $ipBlocked = true; }
$check($ipBlocked, 'die Sperre je IP-Adresse gilt weiterhin');
$known2 = $newJar();
file_put_contents($known2, "# Netscape HTTP Cookie File
#HttpOnly_127.0.0.1 FALSE / FALSE " . (time() + 86400) . " dh_device {$cookie}
");
[, $b] = $attempt($known2, 'eva@test.de', $newPass);
$check(str_contains($b, 'Zu viele Anmeldeversuche. Bitte versuchen'), 'und gilt auch für bekannte Geräte');
@unlink($known2);
foreach ([$jarEva, $knownJar, $attacker, $other, $jarChef, $jarEva2, $resetJar, $ipJar] as $j) @unlink($j);
} finally {
if (is_resource($webProc)) { proc_terminate($webProc); proc_close($webProc); }
$rm = function (string $d) use (&$rm): void { foreach (glob($d . '/{,.}*', GLOB_BRACE) ?: [] as $e) { $b = basename($e); if ($b === '.' || $b === '..') continue; is_dir($e) ? $rm($e) : @unlink($e); } @rmdir($d); };
$rm($work);
}
if ($failures) {
fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n");
exit(1);
}
echo "Login lock checks passed ({$total} Prüfungen).\n";
+82
View File
@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
/** Prüft login_guard_is_locked() und login_guard_unlock(): Web-, App- und 2FA-Zähler, andere Konten und die IP-Sperre bleiben unberührt. */
putenv('DESKHUB_TESTING=1');
putenv('DESKHUB_SQLITE_PATH=:memory:');
require_once dirname(__DIR__) . '/includes/bootstrap.php';
$failures = [];
$total = 0;
$check = function (bool $ok, string $what) use (&$failures, &$total): void {
$total++;
if (!$ok) $failures[] = $what;
};
$pdo = get_db();
$now = date('Y-m-d H:i:s');
$dept = (int) $pdo->query('SELECT id FROM departments ORDER BY id LIMIT 1')->fetchColumn();
$pdo->exec('DELETE FROM staff');
$ins = $pdo->prepare('INSERT INTO staff (name, email, password_hash, role, department_id, is_active, created_at) VALUES (?,?,?,?,?,1,?)');
$ins->execute(['Eva', 'Eva@Test.de', 'x', 'agent', $dept, $now]);
$eva = (int) $pdo->lastInsertId();
$ins->execute(['Max', 'max@test.de', 'x', 'agent', $dept, $now]);
$max = (int) $pdo->lastInsertId();
$log = function (string $bucket, string $id, int $n, ?string $at = null) use ($pdo, $now): void {
for ($i = 0; $i < $n; $i++) $pdo->prepare('INSERT INTO rate_limit_log (bucket, identifier, created_at) VALUES (?,?,?)')->execute([$bucket, $id, $at ?? $now]);
};
$count = fn(string $where) => (int) $pdo->query("SELECT COUNT(*) FROM rate_limit_log WHERE {$where}")->fetchColumn();
$max5 = login_guard_max_attempts();
$check($max5 === 5 && login_guard_lock_minutes() === 15, 'Standard: 5 Versuche, 15 Minuten');
$check(!login_guard_is_locked($pdo, 'eva@test.de'), 'ohne Versuche nicht gesperrt');
$log('staff_login_email', 'eva@test.de', 4);
$check(!login_guard_is_locked($pdo, 'eva@test.de'), 'vier Versuche: noch nicht gesperrt');
$log('mobile_staff_login_email', 'eva@test.de', 1);
$check(login_guard_is_locked($pdo, 'EVA@test.de '), 'Web und App zusammen erreichen die Grenze (Schreibweise egal)');
$check(!login_guard_is_locked($pdo, 'max@test.de') && !login_guard_is_locked($pdo, ''), 'andere Konten und leere Adresse: nicht gesperrt');
$pdo->exec("DELETE FROM rate_limit_log");
$log('staff_login_email', 'eva@test.de', 6, date('Y-m-d H:i:s', time() - 3600));
$check(!login_guard_is_locked($pdo, 'eva@test.de'), 'alte Versuche außerhalb des Zeitfensters zählen nicht');
$pdo->exec("DELETE FROM rate_limit_log");
$log('staff_login_email', 'eva@test.de', 5);
$log('mobile_staff_login_email', 'eva@test.de', 2);
$log('staff_totp_attempt', (string) $eva, 3);
$log('staff_login_email', 'max@test.de', 5);
$log('staff_totp_attempt', (string) $max, 2);
$log('staff_login_attempt', '203.0.113.5', 9);
$n = login_guard_unlock($pdo, $eva, $max);
$check($n === 10, 'gelöschte Zähler: Web 5 + App 2 + 2FA 3: ' . $n);
$check(!login_guard_is_locked($pdo, 'eva@test.de'), 'danach nicht mehr gesperrt');
$check($count("identifier = 'max@test.de'") === 5 && $count("bucket = 'staff_totp_attempt' AND identifier = '{$max}'") === 2, 'Max bleibt unberührt');
$check($count("bucket = 'staff_login_attempt'") === 9, 'die IP-Sperre bleibt bestehen');
$ev = $pdo->query("SELECT detail FROM login_events WHERE event = 'unlocked' AND staff_id = {$eva}")->fetchColumn();
$check(str_contains((string) $ev, 'Administrator'), 'Protokoll nennt den Administrator');
$check(login_guard_unlock($pdo, 999999) === 0, 'unbekannter Mitarbeiter: nichts passiert');
$check(login_guard_unlock($pdo, $eva, null, 'Sperre aufgehoben: Test') === 0, 'zweites Mal: nichts mehr zu löschen, kein Fehler');
$ev2 = $pdo->query("SELECT detail FROM login_events WHERE event = 'unlocked' AND staff_id = {$eva} ORDER BY id DESC LIMIT 1")->fetchColumn();
$check($ev2 === 'Sperre aufgehoben: Test', 'eigener Grund im Protokoll');
$check(isset(login_guard_event_labels()['unlocked']), 'Ereignisart ist beschriftet');
// Bekanntes Gerät
$check(!login_guard_known_device($pdo, $eva), 'ohne Cookie: kein bekanntes Gerät');
$cookie = bin2hex(random_bytes(16));
$_COOKIE[LOGIN_GUARD_COOKIE] = $cookie;
$check(!login_guard_known_device($pdo, $eva), 'Cookie, aber noch nicht gemerkt: unbekannt');
$pdo->prepare('INSERT INTO login_devices (staff_id, device_hash, label, channel, first_ip, last_ip, place, first_seen, last_seen) VALUES (?,?,?,?,?,?,?,?,?)')
->execute([$eva, hash('sha256', $eva . '|' . $cookie), 'Chrome auf Windows', 'web', '1.1.1.1', '1.1.1.1', 'Netz', $now, $now]);
$check(login_guard_known_device($pdo, $eva) && login_guard_known_device_for_email('eva@test.de'), 'gemerktes Gerät wird erkannt (auch über die E-Mail-Adresse)');
$check(!login_guard_known_device($pdo, $max) && !login_guard_known_device_for_email('max@test.de'), 'für ein anderes Konto gilt das Gerät nicht');
$_COOKIE[LOGIN_GUARD_COOKIE] = 'kein-gueltiges-cookie';
$check(!login_guard_known_device($pdo, $eva), 'ungültiges Cookie: unbekannt');
$_COOKIE[LOGIN_GUARD_COOKIE] = $cookie;
$check(!login_guard_known_device_for_email('niemand@test.de') && !login_guard_known_device_for_email(''), 'unbekannte oder leere Adresse: unbekannt');
$pdo->exec("UPDATE staff SET is_active = 0 WHERE id = {$eva}");
$check(!login_guard_known_device_for_email('eva@test.de'), 'deaktiviertes Konto: unbekannt');
if ($failures) {
fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n");
exit(1);
}
echo "Login unlock checks passed ({$total} Prüfungen).\n";
+66
View File
@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
/**
* Prüft das E-Mail-Design (includes/mailer.php): Aufbau für Outlook und Handys, Knopf mit Outlook-Ersatz, Platzhalter, Escaping von Name und Logo
* und dass keine Layout-Mittel benutzt werden, die Outlook ignoriert (Flexbox, Grid, position, <style>-Blöcke).
*/
putenv('DESKHUB_TESTING=1');
putenv('DESKHUB_SQLITE_PATH=:memory:');
require_once dirname(__DIR__) . '/includes/bootstrap.php';
$failures = [];
$total = 0;
$check = function (bool $ok, string $what) use (&$failures, &$total): void {
$total++;
if (!$ok) $failures[] = $what;
};
set_setting('display_name', 'Muster <b>Support</b> & Co');
set_setting('site_url', 'https://support.example.de');
set_setting('logo_path', '');
$html = email_wrap(email_p('Hallo') . email_badge('Ticket', '#123') . email_button('Ticket ansehen', 'https://support.example.de/t?n=1&x=2'));
$check(str_contains($html, 'width="600"') && str_contains($html, 'max-width:600px'), 'feste Breite 600 für Outlook, auf kleinen Bildschirmen schrumpfend');
$check(substr_count($html, 'role="presentation"') >= 4, 'Layout besteht aus Tabellen');
$check(!preg_match('/display\s*:\s*(flex|grid)|position\s*:\s*(absolute|fixed)|<style|@media|float\s*:/i', $html), 'keine Layout-Mittel, die Outlook ignoriert');
$check(str_contains($html, 'Muster &lt;b&gt;Support&lt;/b&gt; &amp; Co') && !str_contains($html, '<b>Support</b>'), 'Name der Seite wird maskiert');
$check(str_contains($html, '<!--[if mso]><v:roundrect') && str_contains($html, 'href="https://support.example.de/t?n=1&x=2"'), 'Knopf hat einen Ersatz für Outlook (VML) und den Link');
$check(preg_match('/<a href="[^"]+" style="display:inline-block;padding:14px 30px/', $html) === 1, 'Knopf ist sonst ein Link mit Innenabstand');
$check(preg_match('#<table[^>]*width="100%"[^>]*>\s*<tr>\s*<td width="5"#', $html) === 1, 'Ticketkarte nutzt die volle Breite mit Farbbalken');
$check(str_contains($html, 'bgcolor="#0f6f6a"'), 'Farben stehen auch als bgcolor (für ältere Clients)');
$check(!str_contains($html, 'src='), 'ohne Logo kein Bild');
// Logo
set_setting('logo_path', '/uploads/logo.png');
$withLogo = email_wrap('x');
$check(str_contains($withLogo, '<img src="https://support.example.de/uploads/logo.png"') && str_contains($withLogo, 'Muster &lt;b&gt;'), 'Logo mit absoluter Adresse, daneben der Name');
set_setting('logo_hide_name', '1');
$logoOnly = email_wrap('x');
$check(str_contains($logoOnly, '<img src=') && !str_contains($logoOnly, 'Muster &lt;b&gt;Support&lt;/b&gt; &amp; Co</span>'), 'Nur das Logo, wenn der Name ausgeblendet ist');
set_setting('logo_path', 'javascript:alert(1)');
$bad = email_wrap('x');
$check(!str_contains($bad, 'javascript:') && !str_contains($bad, '<img'), 'Ungültige Logo-Adresse wird ignoriert');
set_setting('logo_path', ''); set_setting('logo_hide_name', '0');
// Standardvorlagen: Platzhalter werden ersetzt, auch im Outlook-Ersatz des Knopfes
$vars = ['ticket_number' => 'T-1', 'subject' => 'Betreff', 'customer_name' => 'Erika', 'customer_email' => 'e@example.org', 'department_name' => 'Technik', 'ticket_url' => 'https://support.example.de/x?n=T-1',
'lookup_url' => 'https://support.example.de/l', 'message' => '<p>Antwort</p>', 'close_note' => '', 'transcript' => ''];
foreach ([
'ticket_created' => email_ticket_created_default_body(), 'new_reply' => email_new_reply_default_body(), 'new_ticket_department' => email_new_ticket_department_default_body(),
'ticket_overdue' => email_ticket_overdue_default_body(), 'live_chat_transcript' => email_live_chat_transcript_default_body(), 'customer_reminder' => email_customer_reminder_default_body(),
] as $key => $default) {
[$subject, $body] = render_email_template($key, $vars, 'x', $default);
$full = email_wrap($body);
$check(!preg_match('/\{[a-z_]+\}/', $full), "Vorlage {$key}: alle Platzhalter ersetzt");
$check(substr_count($full, 'https://support.example.de/x?n=T-1') >= 2 || $key === 'ticket_overdue' || str_contains($full, 'x?n=T-1'), "Vorlage {$key}: Knopf-Link enthalten");
$check(preg_match('/<h2 /', $full) === 1, "Vorlage {$key}: hat eine Überschrift");
}
$reset = email_password_reset_body('https://support.example.de/reset?t=abc');
$check(str_contains($reset, 'Passwort zurücksetzen') && str_contains($reset, 'reset?t=abc') && str_contains($reset, 'width="600"'), 'Passwort-Mail im neuen Rahmen mit Knopf');
$check(str_contains(email_ticket_overdue_default_body(), '#b91c1c') && str_contains(email_ticket_overdue_default_body(), '#dc2626'), 'Überfällig-Mail bleibt rot');
echo $failures ? "FEHLGESCHLAGEN:\n - " . implode("\n - ", $failures) . "\n" : '';
echo "Mail-Design-Test: " . ($total - count($failures)) . " von {$total} Prüfungen in Ordnung.\n";
exit($failures ? 1 : 0);