Vier Luecken geschlossen, die im Alltag sofort aufgefallen waeren: - Passwort aendern war im eingeloggten Zustand gar nicht moeglich; es gab nur den Reset per Mail-Link. saas_change_password() prueft das aktuelle Passwort, verlangt ein tatsaechlich anderes und erneuert danach die Session-ID. Formular in konto.php. - mandant-auswahl.php war nur direkt nach dem Login erreichbar. Wer bei mehreren Mandanten Mitglied ist, musste sich zum Wechseln abmelden. Die Seite bedient jetzt beide Wege, die Mandantenpruefung bleibt unveraendert ueber saas_identity_for_user_tenant(). Menuepunkt ab zwei Mitgliedschaften. - email_verified_at wurde nirgends geprueft, nur angezeigt - bei offener Selbstregistrierung konnte sich jemand mit fremder Adresse anmelden und alles nutzen. Erzwungen wird jetzt gezielt dort, wo eine Aktion nach aussen wirkt: Einladung, Info-Mail, Jahresabschluss. Der Login selbst bleibt bewusst frei, sonst waeren alle migrierten Bestandsnutzer mit NULL-Verifikation ausgesperrt. Zusaetzlich setzt der Passwort-Reset die Verifikation mit, weil der Mail-Link den Postfachzugriff nachweist - sonst blieben eingeladene Mitglieder dauerhaft unbestaetigt. - Login landete auf konto.php statt auf dem Dashboard. Landingpage: die gruene Vertrauenszeile auf "DSGVO-konform" gekuerzt, die Eintraege zu Paragraf 19 UStG und "Bestehende Ablaeufe bleiben" entfernt. Geprueft: neues scripts/check-konto-und-mandantenwechsel.php mit 18 Assertions gruen, Passwortformular zusaetzlich manuell inkl. CSRF (419). Bestehende Suiten unveraendert gruen: HTTP-Smoke 34 Seiten, Rollenmatrix 55, Mandanten-Isolation 12, M3-Auth 9, M3-Settings 15. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
312 lines
12 KiB
PHP
312 lines
12 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
/**
|
|
* Prueft die vier Kontofunktionen, die vor dem Go-Live ergaenzt wurden:
|
|
*
|
|
* 1. Passwortwechsel im eingeloggten Zustand (saas_change_password).
|
|
* 2. Mandantenwechsel aus einer bestehenden Sitzung heraus.
|
|
* 3. Erzwungene E-Mail-Verifikation fuer Aktionen, die nach aussen mailen.
|
|
* 4. Login landet auf dem Dashboard statt auf der Kontoseite.
|
|
*
|
|
* Braucht einen laufenden Dev-Server (scripts/run-dev-server.sh).
|
|
* Legt eigene Testmandanten an und raeumt sie am Ende wieder ab.
|
|
*/
|
|
|
|
require __DIR__ . '/dev-db.php';
|
|
require_once __DIR__ . '/../app/saas-auth.php';
|
|
|
|
$baseUrl = rtrim((string)(getenv('SMOKE_BASE_URL') ?: 'http://127.0.0.1:8080'), '/');
|
|
$pdo = dev_pdo();
|
|
$suffix = bin2hex(random_bytes(4));
|
|
$altesPasswort = 'KontoCheckAlt123!';
|
|
$neuesPasswort = 'KontoCheckNeu456!';
|
|
|
|
/**
|
|
* Minimaler Cookie-Jar auf Basis von file_get_contents, weil diese
|
|
* PHP-Installation keine curl-Extension hat - gleiches Vorgehen wie in
|
|
* scripts/check-m8-role-matrix.php.
|
|
*
|
|
* @param array<string,string> $cookies
|
|
* @return array{status:int, body:string, location:?string}
|
|
*/
|
|
function konto_request(string $url, array &$cookies, string $method = 'GET', ?string $postBody = null): array
|
|
{
|
|
$cookieHeader = '';
|
|
if ($cookies !== []) {
|
|
$pairs = [];
|
|
foreach ($cookies as $name => $value) {
|
|
$pairs[] = "{$name}={$value}";
|
|
}
|
|
$cookieHeader = 'Cookie: ' . implode('; ', $pairs) . "\r\n";
|
|
}
|
|
|
|
$headers = "User-Agent: KaffeelisteKontoCheck/1.0\r\n" . $cookieHeader;
|
|
if ($postBody !== null) {
|
|
$headers .= "Content-Type: application/x-www-form-urlencoded\r\n";
|
|
}
|
|
|
|
$context = stream_context_create([
|
|
'http' => [
|
|
'method' => $method,
|
|
'header' => $headers,
|
|
'content' => $postBody,
|
|
'timeout' => 15,
|
|
'ignore_errors' => true,
|
|
'follow_location' => 0,
|
|
],
|
|
]);
|
|
|
|
$body = @file_get_contents($url, false, $context);
|
|
$responseHeaders = $http_response_header ?? [];
|
|
$status = 0;
|
|
$location = null;
|
|
|
|
foreach ($responseHeaders as $header) {
|
|
if (preg_match('~^HTTP/\S+\s+(\d{3})~', $header, $m) === 1) {
|
|
$status = (int)$m[1];
|
|
}
|
|
if (preg_match('/^Set-Cookie:\s*([^=;]+)=([^;]+)/i', $header, $m) === 1) {
|
|
$cookies[$m[1]] = $m[2];
|
|
}
|
|
if (preg_match('/^Location:\s*(.+)$/i', $header, $m) === 1) {
|
|
$location = trim($m[1]);
|
|
}
|
|
}
|
|
|
|
return ['status' => $status, 'body' => (string)$body, 'location' => $location];
|
|
}
|
|
|
|
/** @param array<string,string> $cookies */
|
|
function konto_csrf(string $url, array &$cookies): string
|
|
{
|
|
$page = konto_request($url, $cookies);
|
|
preg_match('/name="csrf_token" value="([^"]+)"/', $page['body'], $m);
|
|
|
|
return $m[1] ?? '';
|
|
}
|
|
|
|
$failures = [];
|
|
$passes = 0;
|
|
|
|
function pruefe(string $label, bool $ok): void
|
|
{
|
|
global $failures, $passes;
|
|
if ($ok) {
|
|
$passes++;
|
|
echo "PASS {$label}\n";
|
|
} else {
|
|
$failures[] = $label;
|
|
echo "FAIL {$label}\n";
|
|
}
|
|
}
|
|
|
|
$tenantAId = null;
|
|
$tenantBId = null;
|
|
$tenantCId = null;
|
|
$userId = null;
|
|
$participantId = null;
|
|
|
|
try {
|
|
// Zwei Mandanten fuer den Wechsel plus ein dritter, in dem der Testnutzer
|
|
// ausdruecklich kein Mitglied ist (Negativfall).
|
|
foreach (['a', 'b', 'c'] as $key) {
|
|
$stmt = $pdo->prepare('INSERT INTO tenants (slug, name, status) VALUES (?, ?, ?)');
|
|
$stmt->execute(["kontocheck-{$key}-{$suffix}", "Kontocheck {$key}", 'active']);
|
|
$id = (int)$pdo->lastInsertId();
|
|
$pdo->prepare('INSERT INTO tenant_settings (tenant_id) VALUES (?)')->execute([$id]);
|
|
${'tenant' . strtoupper($key) . 'Id'} = $id;
|
|
}
|
|
|
|
$email = "kontocheck-{$suffix}@test.local";
|
|
$pdo->prepare(
|
|
'INSERT INTO users (email, email_norm, display_name, password_hash, status) VALUES (?, ?, ?, ?, ?)'
|
|
)->execute([$email, $email, 'Kontocheck Nutzer', password_hash($altesPasswort, PASSWORD_DEFAULT), 'active']);
|
|
$userId = (int)$pdo->lastInsertId();
|
|
|
|
foreach ([$tenantAId, $tenantBId] as $tid) {
|
|
$pdo->prepare(
|
|
'INSERT INTO tenant_memberships (tenant_id, user_id, role, status, joined_at) VALUES (?, ?, ?, ?, NOW())'
|
|
)->execute([$tid, $userId, 'owner', 'active']);
|
|
}
|
|
|
|
// Teilnehmer in Mandant A, dem die Einladung gelten soll.
|
|
$einladungsMail = "kontocheck-invite-{$suffix}@test.local";
|
|
$pdo->prepare(
|
|
'INSERT INTO participants (tenant_id, display_name, email, email_norm, active) VALUES (?, ?, ?, ?, 1)'
|
|
)->execute([$tenantAId, 'Einzuladendes Mitglied', $einladungsMail, $einladungsMail]);
|
|
$participantId = (int)$pdo->lastInsertId();
|
|
} catch (Throwable $e) {
|
|
fwrite(STDERR, "Kontocheck-Setup fehlgeschlagen: {$e->getMessage()}\n");
|
|
exit(1);
|
|
}
|
|
|
|
try {
|
|
// ---------------------------------------------------------------
|
|
// 4. Login landet auf dem Dashboard
|
|
// ---------------------------------------------------------------
|
|
$cookies = [];
|
|
$csrf = konto_csrf("{$baseUrl}/login.php", $cookies);
|
|
$login = konto_request("{$baseUrl}/login.php", $cookies, 'POST', http_build_query([
|
|
'csrf_token' => $csrf,
|
|
'email' => "kontocheck-{$suffix}@test.local",
|
|
'password' => $altesPasswort,
|
|
'tenant_slug' => "kontocheck-a-{$suffix}",
|
|
]));
|
|
pruefe('Login leitet auf index.php statt konto.php', $login['location'] === 'index.php');
|
|
|
|
// ---------------------------------------------------------------
|
|
// 3. Verifikation wird fuer Einladungen erzwungen
|
|
// ---------------------------------------------------------------
|
|
$csrf = konto_csrf("{$baseUrl}/mitarbeiterverwalten.php", $cookies);
|
|
$einladung = konto_request("{$baseUrl}/mitarbeiterverwalten.php", $cookies, 'POST', http_build_query([
|
|
'csrf_token' => $csrf,
|
|
'aktion' => 'zugang_gewaehren',
|
|
'mitgliedID' => (string)$participantId,
|
|
'rolle' => 'member',
|
|
]));
|
|
pruefe(
|
|
'Einladung ohne bestaetigte Adresse wird abgelehnt',
|
|
str_contains($einladung['body'], 'bestätigt sein')
|
|
);
|
|
|
|
$stmt = $pdo->prepare('SELECT user_id FROM participants WHERE id = ?');
|
|
$stmt->execute([$participantId]);
|
|
pruefe('Ohne Verifikation wurde kein Konto angelegt', (int)($stmt->fetchColumn() ?: 0) === 0);
|
|
|
|
// Adresse bestaetigen und dieselbe Aktion erneut versuchen.
|
|
$pdo->prepare('UPDATE users SET email_verified_at = NOW() WHERE id = ?')->execute([$userId]);
|
|
|
|
$csrf = konto_csrf("{$baseUrl}/mitarbeiterverwalten.php", $cookies);
|
|
$einladung = konto_request("{$baseUrl}/mitarbeiterverwalten.php", $cookies, 'POST', http_build_query([
|
|
'csrf_token' => $csrf,
|
|
'aktion' => 'zugang_gewaehren',
|
|
'mitgliedID' => (string)$participantId,
|
|
'rolle' => 'member',
|
|
]));
|
|
pruefe(
|
|
'Einladung mit bestaetigter Adresse funktioniert',
|
|
str_contains($einladung['body'], 'Zugang wurde gewährt')
|
|
);
|
|
|
|
// ---------------------------------------------------------------
|
|
// 2. Mandantenwechsel aus bestehender Sitzung
|
|
// ---------------------------------------------------------------
|
|
$auswahl = konto_request("{$baseUrl}/mandant-auswahl.php", $cookies);
|
|
pruefe(
|
|
'Mandantenwechsel ist eingeloggt erreichbar (kein Redirect auf login.php)',
|
|
$auswahl['status'] === 200 && str_contains($auswahl['body'], 'Mandant wechseln')
|
|
);
|
|
|
|
$csrf = konto_csrf("{$baseUrl}/mandant-auswahl.php", $cookies);
|
|
$wechsel = konto_request("{$baseUrl}/mandant-auswahl.php", $cookies, 'POST', http_build_query([
|
|
'csrf_token' => $csrf,
|
|
'tenant_id' => (string)$tenantBId,
|
|
]));
|
|
pruefe('Wechsel auf eigenen zweiten Mandanten leitet aufs Dashboard', $wechsel['location'] === 'index.php');
|
|
|
|
$konto = konto_request("{$baseUrl}/konto.php", $cookies);
|
|
pruefe(
|
|
'Nach dem Wechsel ist Mandant B aktiv',
|
|
str_contains($konto['body'], "kontocheck-b-{$suffix}")
|
|
);
|
|
|
|
$csrf = konto_csrf("{$baseUrl}/mandant-auswahl.php", $cookies);
|
|
$fremd = konto_request("{$baseUrl}/mandant-auswahl.php", $cookies, 'POST', http_build_query([
|
|
'csrf_token' => $csrf,
|
|
'tenant_id' => (string)$tenantCId,
|
|
]));
|
|
pruefe(
|
|
'Wechsel auf fremden Mandanten wird abgelehnt',
|
|
$fremd['location'] === null && str_contains($fremd['body'], 'nicht verfügbar')
|
|
);
|
|
|
|
$konto = konto_request("{$baseUrl}/konto.php", $cookies);
|
|
pruefe(
|
|
'Nach abgelehntem Wechsel ist weiterhin Mandant B aktiv',
|
|
str_contains($konto['body'], "kontocheck-b-{$suffix}")
|
|
);
|
|
|
|
// ---------------------------------------------------------------
|
|
// 1. Passwortwechsel
|
|
// ---------------------------------------------------------------
|
|
$falsch = saas_change_password($pdo, $userId, 'VoelligFalsch999!', $neuesPasswort, $neuesPasswort);
|
|
pruefe('Falsches aktuelles Passwort wird abgelehnt', $falsch['ok'] === false);
|
|
|
|
$ungleich = saas_change_password($pdo, $userId, $altesPasswort, $neuesPasswort, 'tippfehler');
|
|
pruefe('Abweichende Wiederholung wird abgelehnt', $ungleich['ok'] === false);
|
|
|
|
$zuKurz = saas_change_password($pdo, $userId, $altesPasswort, 'kurz', 'kurz');
|
|
pruefe('Zu kurzes Passwort wird abgelehnt', $zuKurz['ok'] === false);
|
|
|
|
$gleich = saas_change_password($pdo, $userId, $altesPasswort, $altesPasswort, $altesPasswort);
|
|
pruefe('Unveraendertes Passwort wird abgelehnt', $gleich['ok'] === false);
|
|
|
|
$stmt = $pdo->prepare('SELECT password_hash FROM users WHERE id = ?');
|
|
$stmt->execute([$userId]);
|
|
pruefe(
|
|
'Nach allen Fehlversuchen gilt weiterhin das alte Passwort',
|
|
password_verify($altesPasswort, (string)$stmt->fetchColumn())
|
|
);
|
|
|
|
$ok = saas_change_password($pdo, $userId, $altesPasswort, $neuesPasswort, $neuesPasswort);
|
|
pruefe('Korrekter Passwortwechsel wird gespeichert', $ok['ok'] === true);
|
|
|
|
// Der Wechsel muss sich auch am echten Login zeigen.
|
|
$neueCookies = [];
|
|
$csrf = konto_csrf("{$baseUrl}/login.php", $neueCookies);
|
|
$altLogin = konto_request("{$baseUrl}/login.php", $neueCookies, 'POST', http_build_query([
|
|
'csrf_token' => $csrf,
|
|
'email' => "kontocheck-{$suffix}@test.local",
|
|
'password' => $altesPasswort,
|
|
'tenant_slug' => "kontocheck-a-{$suffix}",
|
|
]));
|
|
pruefe('Login mit altem Passwort schlaegt fehl', $altLogin['location'] === null);
|
|
|
|
$neueCookies = [];
|
|
$csrf = konto_csrf("{$baseUrl}/login.php", $neueCookies);
|
|
$neuLogin = konto_request("{$baseUrl}/login.php", $neueCookies, 'POST', http_build_query([
|
|
'csrf_token' => $csrf,
|
|
'email' => "kontocheck-{$suffix}@test.local",
|
|
'password' => $neuesPasswort,
|
|
'tenant_slug' => "kontocheck-a-{$suffix}",
|
|
]));
|
|
pruefe('Login mit neuem Passwort funktioniert', $neuLogin['location'] === 'index.php');
|
|
|
|
// ---------------------------------------------------------------
|
|
// Reset-Link bestaetigt die Adresse mit
|
|
// ---------------------------------------------------------------
|
|
$pdo->prepare('UPDATE users SET email_verified_at = NULL WHERE id = ?')->execute([$userId]);
|
|
$token = saas_create_auth_token($pdo, $userId, 'password_reset', $tenantAId, 60);
|
|
$reset = saas_reset_password_with_token($pdo, $token['token'], 'ResetCheck789!', 'ResetCheck789!');
|
|
$stmt = $pdo->prepare('SELECT email_verified_at FROM users WHERE id = ?');
|
|
$stmt->execute([$userId]);
|
|
pruefe(
|
|
'Passwort-Reset per Mail-Link bestaetigt die Adresse mit',
|
|
$reset['ok'] === true && $stmt->fetchColumn() !== null
|
|
);
|
|
} finally {
|
|
// Aufraeumen: Mitgliedschaften/Teilnehmer haengen per Fremdschluessel an
|
|
// den Mandanten, der Nutzer wird separat entfernt.
|
|
foreach ([$tenantAId, $tenantBId, $tenantCId] as $tid) {
|
|
if ($tid !== null) {
|
|
$pdo->prepare('DELETE FROM tenants WHERE id = ?')->execute([$tid]);
|
|
}
|
|
}
|
|
if ($userId !== null) {
|
|
$pdo->prepare('DELETE FROM user_auth_tokens WHERE user_id = ?')->execute([$userId]);
|
|
$pdo->prepare('DELETE FROM users WHERE id = ?')->execute([$userId]);
|
|
}
|
|
// Das eingeladene Mitglied bekam bei Erfolg ein eigenes Benutzerkonto.
|
|
$pdo->prepare('DELETE FROM users WHERE email_norm = ?')
|
|
->execute(["kontocheck-invite-{$suffix}@test.local"]);
|
|
}
|
|
|
|
if ($failures !== []) {
|
|
fwrite(STDERR, "\nKontocheck fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n");
|
|
exit(1);
|
|
}
|
|
|
|
echo "\nKonto- und Mandantenwechsel-Check bestanden mit {$passes} Assertions.\n";
|