From d04a76b6eea071d0cbfa81fae012e1174d59a579 Mon Sep 17 00:00:00 2001 From: nexxo Date: Tue, 4 Aug 2026 02:24:13 +0200 Subject: [PATCH] =?UTF-8?q?Ein=20R=C3=BCckweg=20aus=20der=20abgelaufenen?= =?UTF-8?q?=20Betreiber-Einladung?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Läuft der 72-Stunden-Link ab, gab es keinen Weg zurück ausser der Shell — und die setzt dabei ungefragt auf Owner. Settings::resendInvitation() zieht für ein noch nie benutztes Konto (last_login_at ist null) ein neues Token und verschickt dieselbe Einladungsmail; der Broker macht das alte Token dabei von selbst ungültig. inviteStaff() läuft jetzt in einer Transaktion, damit eine Störung zwischen Kontoanlage und Mailversand keine für immer blockierte Adresse mehr hinterlässt. Dazu der Nachtrag am Testnachweis: die reflektierende Prüfung auf ein verstecktes Passwort steigt jetzt in Arrays ab statt sie zu überspringen, und prüft zusätzlich das gerenderte HTML statt nur die Komponenteneigen- schaften. Drei Kleinigkeiten: ein falscher Kommentarhinweis auf eine angeblich fehlende Übersetzungsdatei korrigiert, eine fehlende Zusicherung gegen einen rohen Statusschlüssel ergänzt, und ein toter throttle-Wert aus config/auth.php entfernt. Co-Authored-By: Claude Opus 5 --- app/Livewire/Admin/Settings.php | 134 ++++++++--- app/Livewire/Auth/OperatorSetPassword.php | 12 +- config/auth.php | 10 +- lang/de/admin_settings.php | 5 + lang/en/admin_settings.php | 5 + .../views/livewire/admin/settings.blade.php | 16 +- tests/Feature/Admin/AdminSettingsTest.php | 16 ++ tests/Feature/Auth/OperatorInvitationTest.php | 212 ++++++++++++++++-- 8 files changed, 353 insertions(+), 57 deletions(-) diff --git a/app/Livewire/Admin/Settings.php b/app/Livewire/Admin/Settings.php index 563985d..1080a45 100644 --- a/app/Livewire/Admin/Settings.php +++ b/app/Livewire/Admin/Settings.php @@ -237,47 +237,113 @@ class Settings extends Component return; } - // Ein Konto, dessen Passwort niemand kennt — auch der Inhaber nicht. + // Alles, was diese Einladung anlegt, in EINER Transaktion. // - // Hier wurde bis zuletzt ein Passwort erzeugt und dem Inhaber einmal - // angezeigt, damit er es „sicher weitergibt": per Chat, per Zuruf. Für - // einen Zugang zur Konsole, die die ganze Flotte und jede - // Kundeninstanz verwaltet. Ein Passwort, das zwei Menschen kennen, - // gehört keinem von beiden. - // - // Die Zufallszeichenkette unten ist deshalb kein „temporäres - // Passwort", sondern das Gegenteil: sie entsteht in dieser Zeile, wird - // vom `hashed`-Cast verhasht und ist danach fort — sie steht in keiner - // Eigenschaft, in keiner Mail und in keinem Protokoll. Anmeldbar wird - // das Konto erst, wenn der Eingeladene über den Link sein eigenes - // Passwort vergibt (App\Livewire\Auth\OperatorSetPassword). - $newStaff = Operator::create([ - 'name' => $data['staffName'], - 'email' => $data['staffEmail'], - 'password' => Str::random(64), - ]); - $newStaff->assignRole($data['staffRole']); + // Vorher liefen Operator::create() und assignRole() fest, bevor der + // Broker den Token zog und die Mail hinausging. Ging die Datenbank + // oder Redis genau dazwischen kurz weg, blieb ein Konto stehen, das + // nie eine Einladung bekommen hatte — und `unique:operators,email` + // blockierte seine Adresse danach für immer, ohne einen Rückweg + // ausser der Shell. Eine vorübergehende Störung darf keinen + // dauerhaften Schaden hinterlassen: bricht hier irgendetwas ab, rollt + // die Transaktion Konto, Rolle und Token gemeinsam zurück. + DB::transaction(function () use ($data, $operator) { + // Ein Konto, dessen Passwort niemand kennt — auch der Inhaber nicht. + // + // Hier wurde bis zuletzt ein Passwort erzeugt und dem Inhaber einmal + // angezeigt, damit er es „sicher weitergibt": per Chat, per Zuruf. Für + // einen Zugang zur Konsole, die die ganze Flotte und jede + // Kundeninstanz verwaltet. Ein Passwort, das zwei Menschen kennen, + // gehört keinem von beiden. + // + // Die Zufallszeichenkette unten ist deshalb kein „temporäres + // Passwort", sondern das Gegenteil: sie entsteht in dieser Zeile, wird + // vom `hashed`-Cast verhasht und ist danach fort — sie steht in keiner + // Eigenschaft, in keiner Mail und in keinem Protokoll. Anmeldbar wird + // das Konto erst, wenn der Eingeladene über den Link sein eigenes + // Passwort vergibt (App\Livewire\Auth\OperatorSetPassword). + $newStaff = Operator::create([ + 'name' => $data['staffName'], + 'email' => $data['staffEmail'], + 'password' => Str::random(64), + ]); + $newStaff->assignRole($data['staffRole']); - // Der Token kommt vom BETREIBER-Broker: eigene Tabelle, eigener - // Provider (R21, siehe config/auth.php). Nicht sendResetLink() — - // das verschickt Laravels eigene Benachrichtigung mit einem Link auf - // die Seite des PORTALS und wäre genau die Vermischung, die diese - // Trennung beendet hat. - $token = Password::broker('operators')->createToken($newStaff); + // Der Token kommt vom BETREIBER-Broker: eigene Tabelle, eigener + // Provider (R21, siehe config/auth.php). Nicht sendResetLink() — + // das verschickt Laravels eigene Benachrichtigung mit einem Link auf + // die Seite des PORTALS und wäre genau die Vermischung, die diese + // Trennung beendet hat. + $token = Password::broker('operators')->createToken($newStaff); - $mail = new OperatorInvitationMail( - $newStaff, - $operator->name, - route('admin.invitation', ['token' => $token, 'email' => $newStaff->email]), - OperatorInvitationMail::validHours(), - ); - Mail::mailer($mail->mailer)->to($newStaff->email)->queue($mail); + $mail = new OperatorInvitationMail( + $newStaff, + $operator->name, + route('admin.invitation', ['token' => $token, 'email' => $newStaff->email]), + OperatorInvitationMail::validHours(), + ); + Mail::mailer($mail->mailer)->to($newStaff->email)->queue($mail); + }); $this->reset('staffName', 'staffEmail'); $this->staffRole = 'Support'; $this->dispatch('notify', message: __('admin_settings.staff_invited')); } + /** + * Ein neues Einladungs-Token für ein Konto, das noch niemand benutzt hat. + * + * Für den Vierte-Tag-Fall: der Link war 72 Stunden gültig, Sam war länger + * weg. `unique:operators,email` verweigert danach eine zweite Einladung + * an dieselbe Adresse — das Konto existiert ja schon —, und es gab bisher + * keinen Weg zurück ausser der Shell (clupilot:create-operator), die dabei + * gleich auf `Owner` setzt. Eine Rettung aus einer abgelaufenen + * Support-Einladung, die den Betroffenen zum Inhaber macht, ist keine + * Rettung. + * + * Legt bewusst KEIN neues Konto an — nur einen neuen Token für das + * bestehende. `createToken()` löscht die alte Token-Zeile von sich aus, + * bevor es die neue einfügt (operator_password_reset_tokens hat die + * Adresse als Primärschlüssel, siehe DatabaseTokenRepository::create()): + * der alte Link verliert dadurch seine Gültigkeit, ohne dass hier extra + * aufgeräumt werden müsste. + */ + public function resendInvitation(int $id): void + { + $this->authorize('staff.manage'); + + if (! $operator = $this->currentOperator()) { + return; + } + + $target = Operator::query()->whereKey($id)->first(); + + // Nur für jemanden, der sich noch nie angemeldet hat. `last_login_at` + // bleibt null, bis OperatorLogin::completeLogin() einmal durchläuft, + // und wird sonst nirgends gesetzt — der einzige Bestand, an dem sich + // „hat den Link noch nie benutzt" von „arbeitet hier längst" verlässlich + // unterscheiden lässt. Ein Knopf, der einem angemeldeten Kollegen das + // Passwort neu vergeben liesse, wäre ein Fehler, kein Komfort — und + // schützt nebenbei auch vor der eigenen Kachel: die Person, die diesen + // Knopf drückt, ist selbst angemeldet und hat also selbst schon ein + // gesetztes `last_login_at`. + if ($target === null || ! $target->isOperator() || $target->last_login_at !== null) { + return; + } + + $token = Password::broker('operators')->createToken($target); + + $mail = new OperatorInvitationMail( + $target, + $operator->name, + route('admin.invitation', ['token' => $token, 'email' => $target->email]), + OperatorInvitationMail::validHours(), + ); + Mail::mailer($mail->mailer)->to($target->email)->queue($mail); + + $this->dispatch('notify', message: __('admin_settings.invitation_resent')); + } + public function setStaffRole(int $id, string $role): void { $this->authorize('staff.manage'); @@ -631,6 +697,10 @@ class Settings extends Component 'email' => $u->email, 'role' => $u->roles->first()?->name ?? '—', 'self' => $u->id === Auth::guard('operator')->id(), + // Der Beleg für „hat den Einladungslink noch nie benutzt": wird + // ausschliesslich bei einer echten Anmeldung gesetzt (siehe + // OperatorLogin::completeLogin), nicht beim Anlegen des Kontos. + 'never_signed_in' => $u->last_login_at === null, ]); return view('livewire.admin.settings', [ diff --git a/app/Livewire/Auth/OperatorSetPassword.php b/app/Livewire/Auth/OperatorSetPassword.php index bc61f4c..bc3223d 100644 --- a/app/Livewire/Auth/OperatorSetPassword.php +++ b/app/Livewire/Auth/OperatorSetPassword.php @@ -95,11 +95,13 @@ class OperatorSetPassword extends Component if ($status !== Password::PASSWORD_RESET) { // EINE Meldung für alle Fehlschläge, und ausdrücklich nicht - // __($status): Laravels Statusschlüssel (passwords.token, - // passwords.user) haben in dieser Installation keine - // Übersetzungsdatei und stünden roh als „passwords.token" auf der - // Seite. Sie zu unterscheiden wäre ohnehin falsch — „diese Adresse - // kennen wir nicht" verrät einem Fremden, wer Betreiber ist. + // __($status): NICHT weil passwords.token/passwords.user roh auf + // der Seite stünden — Laravel liefert dazu eine eigene + // Übersetzung mit (lang/en/passwords.php im Framework, und + // fallback_locale ist en), der Schlüssel löst also durchaus auf. + // Der eigentliche Grund ist ein anderer: die Fehlschläge zu + // unterscheiden wäre falsch — „diese Adresse kennen wir nicht" + // verrät einem Fremden, wer Betreiber ist. $this->addError('email', __('auth.console_invite_invalid')); return; diff --git a/config/auth.php b/config/auth.php index 90b2fe5..92e26d8 100644 --- a/config/auth.php +++ b/config/auth.php @@ -136,11 +136,19 @@ return [ // Kommt später eine Seite „Passwort vergessen" für Betreiber dazu, // gehört ihr eine kürzere Frist — dann braucht sie einen eigenen // Broker über derselben Tabelle, statt diese Zahl zu verbiegen. + // + // Kein `throttle` hier: der einzige Leser dieses Werts ist + // sendResetLink() (DatabaseTokenRepository::recentlyCreatedToken()), + // und dieser Broker stellt Token ausschliesslich über createToken() + // aus — inviteStaff() und resendInvitation() in Admin\Settings. + // sendResetLink() würde ohnehin an Operator::sendPasswordResetNotification() + // scheitern, das absichtlich wirft (R21: kein Selbstbedienungs-Weg für + // Betreiber). Fehlt der Schlüssel, liest PasswordBrokerManager 0 — + // ohne Wirkung, weil der Pfad, der ihn braucht, nie läuft. 'operators' => [ 'provider' => 'operators', 'table' => 'operator_password_reset_tokens', 'expire' => 4320, - 'throttle' => 60, ], ], diff --git a/lang/de/admin_settings.php b/lang/de/admin_settings.php index c545cf6..912b120 100644 --- a/lang/de/admin_settings.php +++ b/lang/de/admin_settings.php @@ -31,6 +31,11 @@ return [ // Broker-Einstellung, damit hier nicht eine zweite Frist behauptet wird. 'invite_hint' => 'Wir schicken eine Einladung an diese Adresse. Der Link darin gilt :hours Stunden; das Passwort vergibt der Mitarbeiter selbst — niemand hier bekommt es zu sehen.', 'staff_invited' => 'Einladung verschickt.', + // Nur solange der alte Link noch nie benutzt wurde (Settings::render(), + // never_signed_in) — der Rückweg aus einer abgelaufenen Einladung, ohne + // die Shell und ohne dabei zum Inhaber zu werden. + 'resend_invitation' => 'Einladung erneut senden', + 'invitation_resent' => 'Einladung erneut verschickt.', 'col_person' => 'Person', 'col_actions' => 'Aktionen', diff --git a/lang/en/admin_settings.php b/lang/en/admin_settings.php index 9a31bab..d7b9375 100644 --- a/lang/en/admin_settings.php +++ b/lang/en/admin_settings.php @@ -28,6 +28,11 @@ return [ 'invite' => 'Invite', 'invite_hint' => 'We send an invitation to this address. The link in it is valid for :hours hours; the staff member sets their own password — nobody here gets to see it.', 'staff_invited' => 'Invitation sent.', + // Only while the old link has never been used (Settings::render(), + // never_signed_in) — the way back from an expired invitation, without the + // shell and without becoming the Owner along the way. + 'resend_invitation' => 'Resend invitation', + 'invitation_resent' => 'Invitation resent.', 'col_person' => 'Person', 'col_actions' => 'Actions', diff --git a/resources/views/livewire/admin/settings.blade.php b/resources/views/livewire/admin/settings.blade.php index a16b11a..1472bbf 100644 --- a/resources/views/livewire/admin/settings.blade.php +++ b/resources/views/livewire/admin/settings.blade.php @@ -619,7 +619,21 @@ @endif @unless ($s['self']) - +
+ {{-- Nur, solange der Link noch nie benutzt wurde + (siehe Settings::render(), never_signed_in): wer + schon angemeldet war, braucht kein neues Token, + und ein Knopf, der einem arbeitenden Kollegen das + Passwort neu vergeben liesse, wäre ein Fehler. --}} + @if ($s['never_signed_in']) + + @endif + +
@endunless diff --git a/tests/Feature/Admin/AdminSettingsTest.php b/tests/Feature/Admin/AdminSettingsTest.php index 7a43175..a6e5f4f 100644 --- a/tests/Feature/Admin/AdminSettingsTest.php +++ b/tests/Feature/Admin/AdminSettingsTest.php @@ -85,6 +85,22 @@ it('will not escalate a non-staff user via a tampered id', function () { expect(Operator::role('Admin')->count())->toBe($adminsBefore); }); +it('forbids a non-Owner from resending an invitation', function () { + // Same capability as inviting in the first place — a resend is a token + // for an account nobody has used yet, and issuing one is not a lesser + // action than creating the account was. + Mail::fake(); + + Livewire::actingAs(operator('Owner'), 'operator')->test(Settings::class) + ->set('staffName', 'Sam')->set('staffEmail', 'sam@ops.test')->set('staffRole', 'Support') + ->call('inviteStaff')->assertHasNoErrors(); + + $sam = Operator::query()->where('email', 'sam@ops.test')->firstOrFail(); + + Livewire::actingAs(operator('Admin'), 'operator')->test(Settings::class) + ->call('resendInvitation', $sam->id)->assertForbidden(); +}); + it('protects the last owner and self-role', function () { $owner = operator('Owner'); $support = operator('Support'); diff --git a/tests/Feature/Auth/OperatorInvitationTest.php b/tests/Feature/Auth/OperatorInvitationTest.php index 6a413af..3f22b56 100644 --- a/tests/Feature/Auth/OperatorInvitationTest.php +++ b/tests/Feature/Auth/OperatorInvitationTest.php @@ -46,6 +46,57 @@ function invite(string $email = 'sam@ops.test', string $role = 'Support'): Opera return Operator::query()->where('email', $email)->firstOrFail(); } +/** + * Jede nichtleere Zeichenkette, die unter den öffentlichen Eigenschaften + * einer Komponente steckt — auch dann, wenn sie in einem Feld liegt statt + * selbst eine Eigenschaft zu sein. + * + * Die naheliegendste Rückkehr des alten Kastens war kein String, sondern + * genau das: `public array $invited = ['email' => …, 'password' => $temp]`. + * Eine Prüfung, die nur `is_string($wert)` zulässt und bei allem anderen + * `continue`t, liefe an so einem Feld lautlos vorbei — sie hätte den + * historischen Fehler selbst nicht gefunden. + * + * @return array + */ +function publicStrings(object $instanz): array +{ + $gefunden = []; + + $einsammeln = function ($wert) use (&$gefunden, &$einsammeln) { + if (is_string($wert) && $wert !== '') { + $gefunden[] = $wert; + } elseif (is_array($wert)) { + foreach ($wert as $teil) { + $einsammeln($teil); + } + } + }; + + foreach ((new ReflectionObject($instanz))->getProperties(ReflectionProperty::IS_PUBLIC) as $eigenschaft) { + $einsammeln($eigenschaft->getValue($instanz)); + } + + return $gefunden; +} + +/** + * Jedes Wort ab acht Zeichen aus einem gerenderten Text gegen einen + * Passwort-Hash gehalten — dasselbe Vorgehen, das für die Mail schon gilt + * (siehe unten, „trägt kein Passwort in der Mail"), hier auf HTML statt auf + * eine Mailable angewandt. Träfe irgendwo ein Passwort auf den Bildschirm — + * im Fliesstext, in einem `value`-Attribut, in einem versteckten Feld —, + * schlüge genau eines dieser Wörter an. + */ +function assertNoPasswordOnScreen(string $html, string $hash): void +{ + preg_match_all('/[^\s<>"\']{8,}/', strip_tags($html), $treffer); + + foreach ($treffer[0] as $wort) { + expect(Hash::check($wort, $hash))->toBeFalse("[{$wort}] steht als Passwort auf dem Bildschirm"); + } +} + // ---- Was das Konto beim Anlegen ist: noch niemandes ---- it('legt ein Konto an, in das sich niemand anmelden kann', function () { @@ -67,12 +118,16 @@ it('legt ein Konto an, in das sich niemand anmelden kann', function () { expect(auth('operator')->check())->toBeFalse(); }); -it('trägt das Passwort des neuen Kontos in keiner Eigenschaft der Seite', function () { +it('trägt das Passwort des neuen Kontos in keiner Eigenschaft der Seite und nicht auf dem Bildschirm', function () { // Der eigentliche Fehler war nicht das Passwort, sondern dass es auf einem // Bildschirm stand. Deshalb wird hier nicht auf die zwei gelöschten // Eigenschaften geprüft (die kämen unter anderem Namen wieder), sondern // darauf, dass KEINE öffentliche Eigenschaft dieser Seite das Passwort des - // eben angelegten Kontos ist. + // eben angelegten Kontos ist — auch nicht als Feld in einem Array, so wie + // es der alte Kasten war (`public array $invited = [..., 'password' => …]`). + // Und, weil eine Eigenschaftsprüfung allein nichts über den tatsächlichen + // Bildschirm beweist: dasselbe Vorgehen wie bei der Mail zusätzlich über + // das gerenderte Ergebnis — genau da stand der historische Fehler. Mail::fake(); $seite = Livewire::actingAs(operator('Owner'), 'operator')->test(Settings::class) @@ -82,17 +137,13 @@ it('trägt das Passwort des neuen Kontos in keiner Eigenschaft der Seite', funct $ivy = Operator::query()->where('email', 'ivy@ops.test')->firstOrFail(); $instanz = $seite->instance(); - foreach ((new ReflectionObject($instanz))->getProperties(ReflectionProperty::IS_PUBLIC) as $eigenschaft) { - $wert = $eigenschaft->getValue($instanz); - - if (! is_string($wert) || $wert === '') { - continue; - } - + foreach (publicStrings($instanz) as $wert) { expect(Hash::check($wert, $ivy->password))->toBeFalse( - "Die Eigenschaft {$eigenschaft->getName()} ist das Passwort des neuen Kontos.", + "Ein Eigenschaftswert der Seite ist das Passwort des neuen Kontos: {$wert}", ); } + + assertNoPasswordOnScreen($seite->html(), $ivy->password); }); // ---- Was hinausgeht: ein Link, kein Passwort ---- @@ -318,8 +369,10 @@ it('hält die Frist in Tagen, nicht in Monaten', function () { }); it('nennt keinen rohen Statusschlüssel, wenn der Link nicht gilt', function () { - // `passwords.token` hat in dieser Installation keine Übersetzungsdatei und - // stünde roh mitten auf der Seite. + // Nicht weil `passwords.token` roh auf der Seite stünde — Laravel liefert + // dazu eine eigene Übersetzung mit (siehe OperatorSetPassword::save()) —, + // sondern weil EINE Meldung für alle Fehlschläge gilt: sie zu + // unterscheiden verriete einem Fremden, wer Betreiber ist. Mail::fake(); invite(); @@ -330,7 +383,10 @@ it('nennt keinen rohen Statusschlüssel, wenn der Link nicht gilt', function () ->set('password_confirmation', 'ein-langes-neues-passwort') ->call('save') ->assertHasErrors('email') - ->assertSee(__('auth.console_invite_invalid')); + ->assertSee(__('auth.console_invite_invalid')) + // Die Zusicherung, die der Name tatsächlich verspricht: stünde + // `passwords.token` daneben, bliebe der Test oben trotzdem grün. + ->assertDontSee('passwords.'); }); // ---- Wo der Link hinführt: in die Konsole, hinter ihre Schranken ---- @@ -367,18 +423,138 @@ it('führt die Einladung über eine Route der Konsole, nicht über eine des Port it('zeigt auf der Team-Seite keine Zugangsdaten mehr an', function () { Mail::fake(); - $html = Livewire::actingAs(operator('Owner'), 'operator')->test(Settings::class) + $seite = Livewire::actingAs(operator('Owner'), 'operator')->test(Settings::class) ->set('tab', 'team') ->set('staffName', 'Sam')->set('staffEmail', 'sam@ops.test')->set('staffRole', 'Support') - ->call('inviteStaff') - ->html(); + ->call('inviteStaff'); - // Die drei Zeilen, die den Kasten beschriftet haben, gibt es nicht mehr — - // in keiner der beiden Sprachen. + $sam = Operator::query()->where('email', 'sam@ops.test')->firstOrFail(); + $html = $seite->html(); + + // Die drei Zeilen, die den alten Kasten beschriftet haben, gibt es nicht + // mehr — in keiner der beiden Sprachen. Das allein beweist aber nichts: + // ein Kasten unter NEUEN Schlüsseln käme durch drei fehlende alte + // Schlüssel unbemerkt durch. Der Name dieses Tests verspricht eine + // Aussage über die SEITE, nicht über drei bestimmte Übersetzungsdateien. foreach (['admin_settings.temp_title', 'admin_settings.temp_hint', 'admin_settings.temp_password'] as $schlüssel) { expect(Lang::has($schlüssel, 'de'))->toBeFalse("{$schlüssel} steht noch in lang/de") ->and(Lang::has($schlüssel, 'en'))->toBeFalse("{$schlüssel} steht noch in lang/en"); } + // Die Aussage, die der Name tatsächlich verspricht: der Bildschirm selbst + // enthält kein Zugangsdatum, gleich unter welchem Schlüssel es aufgetaucht + // wäre. + assertNoPasswordOnScreen($html, $sam->password); + expect($html)->toContain(e(__('admin_settings.invite_hint', ['hours' => OperatorInvitationMail::validHours()]))); }); + +// ---- Der Rückweg: läuft der Link ab, ist die Adresse nicht verbrannt ---- + +it('bietet „Einladung erneut senden" nur für jemanden an, der den Link noch nie benutzt hat', function () { + // Sam hat sich nie angemeldet: last_login_at ist null, wie bei jedem + // frisch eingeladenen Konto (OperatorLogin::completeLogin ist der einzige + // Ort, der das Feld setzt). Ivy dagegen ist längst im Team — ein Knopf, + // der ihr das Passwort neu vergäbe, wäre ein Fehler, kein Komfort. + Mail::fake(); + + $sam = invite(); + $ivy = Operator::factory()->role('Support')->create(['last_login_at' => now()]); + + $html = Livewire::actingAs(operator('Owner'), 'operator')->test(Settings::class) + ->set('tab', 'team') + ->html(); + + expect($html)->toContain("resendInvitation({$sam->id})") + ->and($html)->not->toContain("resendInvitation({$ivy->id})"); +}); + +it('stellt beim erneuten Senden ein neues Token aus und macht das alte damit ungültig', function () { + Mail::fake(); + + $sam = invite(); + $altesToken = Password::broker('operators')->createToken($sam); + + Livewire::actingAs(operator('Owner'), 'operator')->test(Settings::class) + ->call('resendInvitation', $sam->id) + ->assertHasNoErrors(); + + // Der Broker löscht die bestehende Token-Zeile beim Ausstellen einer neuen + // von selbst — operator_password_reset_tokens hat die Adresse als + // Primärschlüssel (DatabaseTokenRepository::create() ruft deleteExisting() + // vor dem Insert). Das alte Token verliert dadurch von selbst seine + // Gültigkeit, ohne dass Admin\Settings hier extra aufräumen müsste. + expect(DB::table('operator_password_reset_tokens')->where('email', 'sam@ops.test')->count())->toBe(1); + + Livewire::test(OperatorSetPassword::class, ['token' => $altesToken]) + ->set('email', 'sam@ops.test') + ->set('password', 'ein-langes-neues-passwort') + ->set('password_confirmation', 'ein-langes-neues-passwort') + ->call('save') + ->assertSet('done', false) + ->assertHasErrors('email'); + + expect(Hash::check('ein-langes-neues-passwort', $sam->fresh()->password))->toBeFalse(); +}); + +it('lässt sich mit dem frischen Token aus einer erneut gesendeten Einladung anmelden', function () { + Mail::fake(); + + $sam = invite(); + + Livewire::actingAs(operator('Owner'), 'operator')->test(Settings::class) + ->call('resendInvitation', $sam->id) + ->assertHasNoErrors(); + + Mail::assertQueued(OperatorInvitationMail::class, fn (OperatorInvitationMail $mail) => $mail->hasTo('sam@ops.test')); + + $frisch = Password::broker('operators')->createToken($sam); + + Livewire::test(OperatorSetPassword::class, ['token' => $frisch]) + ->set('email', 'sam@ops.test') + ->set('password', 'ein-langes-neues-passwort') + ->set('password_confirmation', 'ein-langes-neues-passwort') + ->call('save') + ->assertHasNoErrors() + ->assertSet('done', true); + + expect(Hash::check('ein-langes-neues-passwort', $sam->fresh()->password))->toBeTrue(); +}); + +it('bietet den Rückweg nicht für jemanden an, der sich längst angemeldet hat', function () { + // Ein direkter Aufruf, an der Oberfläche vorbei: der Knopf ist zwar + // ausgeblendet, aber die Livewire-Methode ist ein öffentlicher Endpunkt + // wie jede andere. Ein bereits benutztes Konto darf sein Passwort nicht + // über diesen Weg neu bekommen. + Mail::fake(); + + $ivy = Operator::factory()->role('Support')->create(['last_login_at' => now()]); + $vorherigesPasswort = $ivy->password; + + Livewire::actingAs(operator('Owner'), 'operator')->test(Settings::class) + ->call('resendInvitation', $ivy->id); + + Mail::assertNothingQueued(); + expect(DB::table('operator_password_reset_tokens')->where('email', $ivy->email)->exists())->toBeFalse() + ->and($ivy->fresh()->password)->toBe($vorherigesPasswort); +}); + +it('lässt beim Einladen kein halbes Konto zurück, wenn der Mailversand scheitert', function () { + // Vorher liefen Operator::create() und assignRole() fest, bevor der + // Broker den Token zog und die Mail hinausging. Geht die + // Mail-Warteschlange GENAU dazwischen kurz weg — Redis kurz nicht + // erreichbar —, blieb bisher ein Konto stehen, das nie eine Einladung + // bekommen hatte: unique:operators,email hätte jede erneute Einladung an + // dieselbe Adresse für immer abgewiesen. + Mail::shouldReceive('mailer')->once()->andThrow(new RuntimeException('Warteschlange nicht erreichbar')); + + $inhaber = operator('Owner'); + + expect(fn () => Livewire::actingAs($inhaber, 'operator')->test(Settings::class) + ->set('staffName', 'Theo')->set('staffEmail', 'theo@ops.test')->set('staffRole', 'Support') + ->call('inviteStaff'))->toThrow(RuntimeException::class); + + expect(Operator::query()->where('email', 'theo@ops.test')->exists())->toBeFalse( + 'Das Konto blieb stehen, obwohl nie eine Einladung verschickt wurde — die Adresse wäre für immer blockiert.', + )->and(DB::table('operator_password_reset_tokens')->where('email', 'theo@ops.test')->exists())->toBeFalse(); +});