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(); +});