test(Settings::class) ->set('staffName', 'Sam')->set('staffEmail', $email)->set('staffRole', $role) ->call('inviteStaff')->assertHasNoErrors(); auth('operator')->logout(); 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 () { Mail::fake(); $sam = invite(); // Die halbe Zusicherung, und sie muss halten: es gibt kein Passwort, das // dieses Konto öffnet — auch keines, das jemand raten würde, weil er den // Code gelesen hat. foreach (['', 'password', 'Sam', 'sam@ops.test', 'clupilot'] as $versuch) { expect(Hash::check($versuch, $sam->password))->toBeFalse("[{$versuch}] öffnet das Konto"); } Livewire::test(OperatorLogin::class) ->set('email', 'sam@ops.test')->set('password', 'password') ->call('authenticate')->assertHasErrors('email'); expect(auth('operator')->check())->toBeFalse(); }); 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 — 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) ->set('staffName', 'Ivy')->set('staffEmail', 'ivy@ops.test')->set('staffRole', 'Support') ->call('inviteStaff'); $ivy = Operator::query()->where('email', 'ivy@ops.test')->firstOrFail(); $instanz = $seite->instance(); foreach (publicStrings($instanz) as $wert) { expect(Hash::check($wert, $ivy->password))->toBeFalse( "Ein Eigenschaftswert der Seite ist das Passwort des neuen Kontos: {$wert}", ); } assertNoPasswordOnScreen($seite->html(), $ivy->password); }); // ---- Was hinausgeht: ein Link, kein Passwort ---- it('schickt eine Einladung mit dem Link und dem Namen des Einladenden', function () { Mail::fake(); $inhaber = operator('Owner'); Livewire::actingAs($inhaber, 'operator')->test(Settings::class) ->set('staffName', 'Sam')->set('staffEmail', 'sam@ops.test')->set('staffRole', 'Support') ->call('inviteStaff')->assertHasNoErrors(); Mail::assertQueued(OperatorInvitationMail::class, function (OperatorInvitationMail $mail) use ($inhaber) { $html = $mail->render(); return $mail->hasTo('sam@ops.test') // Der Link zeigt auf die Seite der KONSOLE, nicht auf die des // Portals — das ist R21 im Ergebnis statt in der Absicht. && str_contains($mail->url, '/admin/invitation/') && str_contains($html, $mail->url) // Wer eingeladen hat, steht darin: eine Mail, die einem Menschen // einen Konsolenzugang anbietet, ist sonst von einem // Phishing-Versuch nicht zu unterscheiden. && str_contains($html, $inhaber->name); }); }); it('trägt kein Passwort in der Mail', function () { Mail::fake(); $sam = invite(); Mail::assertQueued(OperatorInvitationMail::class, function (OperatorInvitationMail $mail) use ($sam) { // Jedes Wort ab acht Zeichen aus der fertigen Mail gegen den Hash des // Kontos gehalten. Fiele irgendwo ein Passwort hinein — im Fliesstext, // in einem Kasten, im Betreff — schlüge genau eines davon an. preg_match_all('/[^\s<>"\']{8,}/', strip_tags($mail->render()), $treffer); foreach ($treffer[0] as $wort) { expect(Hash::check($wort, $sam->password))->toBeFalse("[{$wort}] ist das Passwort des Kontos"); } return true; }); }); // ---- R21: eigene Tabelle, eigener Broker, eigene Seite ---- it('legt den Token in die Tabelle der Betreiber und nicht in die der Kunden', function () { Mail::fake(); invite(); expect(DB::table('operator_password_reset_tokens')->count())->toBe(1) // Ein Token ist nur ein Zeilenschlüssel. Läge er in der Kundentabelle, // wäre die Trennung der beiden Personengruppen genau hier aufgehoben. ->and(DB::table('password_reset_tokens')->count())->toBe(0); }); it('löst einen Kunden-Token nicht auf der Konsolenseite ein', function () { // Die Adressen dürfen sich heute nicht überschneiden — Admin\Settings weist // eine Kundenadresse ab. Das ist eine Prüfung an EINER Stelle, keine // Struktur, also wird sie hier absichtlich umgangen: beide Datensätze // entstehen direkt. $betreiber = Operator::factory()->role('Support')->create([ 'email' => 'doppelt@ops.test', 'password' => 'passwort-des-betreibers', ]); $kunde = User::factory()->create([ 'email' => 'doppelt@ops.test', 'password' => 'passwort-des-kunden', ]); $kundenToken = Password::broker()->createToken($kunde); Livewire::test(OperatorSetPassword::class, ['token' => $kundenToken]) ->set('email', 'doppelt@ops.test') ->set('password', 'ein-langes-neues-passwort') ->set('password_confirmation', 'ein-langes-neues-passwort') ->call('save') ->assertHasErrors('email') ->assertSet('done', false); expect(Hash::check('passwort-des-betreibers', $betreiber->fresh()->password))->toBeTrue() ->and(Hash::check('ein-langes-neues-passwort', $betreiber->fresh()->password))->toBeFalse(); }); it('löst einen Betreiber-Token nicht auf der Portalseite ein', function () { // Die Gegenrichtung. Sie ist die gefährlichere: ein Token aus der Konsole, // eingelöst im Portal, setzte das Passwort eines KUNDEN. $betreiber = Operator::factory()->role('Support')->create(['email' => 'doppelt@ops.test']); $kunde = User::factory()->create([ 'email' => 'doppelt@ops.test', 'password' => 'passwort-des-kunden', ]); $betreiberToken = Password::broker('operators')->createToken($betreiber); Livewire::test(ResetPassword::class, ['token' => $betreiberToken]) ->set('email', 'doppelt@ops.test') ->set('password', 'ein-langes-neues-passwort') ->set('password_confirmation', 'ein-langes-neues-passwort') ->call('save') ->assertHasErrors('email') ->assertSet('done', false); expect(Hash::check('passwort-des-kunden', $kunde->fresh()->password))->toBeTrue(); }); it('schickt einem Betreiber nicht Laravels eigene Rücksetzmail', function () { // Die Vorgabe aus CanResetPassword verlinkt auf `password.reset` — die // Seite des PORTALS. Ein Betreiber-Token landete damit auf der Kundenseite. // Konkrete Klasse, nicht Throwable: eine Zusicherung auf eine Schnittstelle // beweist nichts. $betreiber = Operator::factory()->role('Support')->create(); expect(fn () => $betreiber->sendPasswordResetNotification('irgendein-token')) ->toThrow(RuntimeException::class); }); // ---- Der Link: einlösen, verbrauchen, ablaufen ---- it('setzt das Passwort und lässt den Eingeladenen sich damit anmelden', function () { Mail::fake(); $sam = invite(); $token = Password::broker('operators')->createToken($sam); Livewire::test(OperatorSetPassword::class, ['token' => $token]) ->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(); Livewire::test(OperatorLogin::class) ->set('email', 'sam@ops.test')->set('password', 'ein-langes-neues-passwort') ->call('authenticate')->assertHasNoErrors(); expect(auth('operator')->id())->toBe($sam->id); }); it('meldet niemanden an, nur weil er den Link geöffnet hat', function () { // Der Link kam per E-Mail. Wer dieses Postfach lesen kann, bekäme sonst die // Sitzung in der Konsole gleich dazu. Mail::fake(); $sam = invite(); Livewire::test(OperatorSetPassword::class, ['token' => Password::broker('operators')->createToken($sam)]) ->set('email', 'sam@ops.test') ->set('password', 'ein-langes-neues-passwort') ->set('password_confirmation', 'ein-langes-neues-passwort') ->call('save'); expect(auth('operator')->check())->toBeFalse(); }); it('verbraucht den Link, damit eine weitergeleitete Mail ihn nicht zweimal öffnet', function () { Mail::fake(); $sam = invite(); $token = Password::broker('operators')->createToken($sam); $einlösen = fn () => Livewire::test(OperatorSetPassword::class, ['token' => $token]) ->set('email', 'sam@ops.test') ->set('password', 'ein-langes-neues-passwort') ->set('password_confirmation', 'ein-langes-neues-passwort') ->call('save'); $einlösen()->assertSet('done', true); $einlösen()->assertSet('done', false)->assertHasErrors('email'); }); it('lässt den Link ablaufen, statt ihn in einem alten Postfach liegen zu lassen', function () { // Ein Einladungslink, der ein Jahr später noch gilt, ist ein Schlüssel zur // Betreiberkonsole in einem Postfach, an das niemand mehr denkt. Mail::fake(); $sam = invite(); $token = Password::broker('operators')->createToken($sam); $stunden = OperatorInvitationMail::validHours(); // Eine Stunde vor Ablauf: gilt noch. $this->travel($stunden - 1)->hours(); Livewire::test(OperatorSetPassword::class, ['token' => $token]) ->set('email', 'sam@ops.test') ->set('password', 'ein-langes-neues-passwort') ->set('password_confirmation', 'ein-langes-neues-passwort') ->call('save') ->assertSet('done', true); // Und derselbe Vorgang jenseits der Frist: ein frischer Token, eine Stunde // nach Ablauf. $frisch = Password::broker('operators')->createToken($sam); $this->travel($stunden + 1)->hours(); Livewire::test(OperatorSetPassword::class, ['token' => $frisch]) ->set('email', 'sam@ops.test') ->set('password', 'ein-noch-viel-neueres-passwort') ->set('password_confirmation', 'ein-noch-viel-neueres-passwort') ->call('save') ->assertSet('done', false) ->assertHasErrors('email'); expect(Hash::check('ein-noch-viel-neueres-passwort', $sam->fresh()->password))->toBeFalse(); }); it('hält die Frist in Tagen, nicht in Monaten', function () { // Der Test darüber prüft den MECHANISMUS: was auch immer eingestellt ist, // läuft ab. Er würde eine Frist von einem Jahr klaglos mittragen, weil er // die Zahl aus derselben Einstellung liest. Die Grössenordnung ist aber // keine Geschmacksfrage: nach oben ist ein Link, der Wochen später noch // gilt, ein Schlüssel zur Betreiberkonsole in einem Postfach, an das // niemand mehr denkt; nach unten wird eine Einladung, die vor dem // Wochenende abläuft, nie geöffnet. Die Zahl dazwischen ist in // config/auth.php begründet. expect(OperatorInvitationMail::validHours())->toBeGreaterThanOrEqual(24) ->and(OperatorInvitationMail::validHours())->toBeLessThanOrEqual(7 * 24); }); it('nennt keinen rohen Statusschlüssel, wenn der Link nicht gilt', function () { // 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(); Livewire::test(OperatorSetPassword::class, ['token' => 'gibt-es-nicht']) ->set('email', 'sam@ops.test') ->set('password', 'ein-langes-neues-passwort') ->set('password_confirmation', 'ein-langes-neues-passwort') ->call('save') ->assertHasErrors('email') ->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 ---- it('legt die Einladungsseite hinter dieselbe Netzbeschränkung wie den Rest der Konsole', function () { // Der Link führt in ein Netz, das der Eingeladene erreichen können muss — // und ein Fremder eben nicht. 404, nie 403: dieselbe Haltung wie überall // sonst in der Konsole. config()->set('admin_access.trusted_ranges', ['10.66.0.0/24']); AppSettings::set('console.network_restricted', true); AppSettings::set('console.allowed_ips', []); $ziel = route('admin.invitation', ['token' => 'beispiel-token', 'email' => 'sam@ops.test']); $this->withServerVariables(['REMOTE_ADDR' => '203.0.113.9'])->get($ziel)->assertNotFound(); $this->withServerVariables(['REMOTE_ADDR' => '10.66.0.5'])->get($ziel)->assertOk(); }); it('führt die Einladung über eine Route der Konsole, nicht über eine des Portals', function () { // Nicht der Pfad, sondern der NAME: er entscheidet in RestrictAdminHost, // ob eine Route als Konsolenroute gilt. $route = app('router')->getRoutes()->getByName('admin.invitation'); expect($route)->not->toBeNull() ->and($route->getName())->toStartWith('admin.') // Im Gast-Teil: wer den Link öffnet, hat noch keinen Zugang, und // routes/admin.php liegt hinter `auth:operator,web`. ->and($route->gatherMiddleware())->toContain('guest:operator') ->and($route->gatherMiddleware())->not->toContain('auth:operator,web'); }); // ---- Was die Seite nicht mehr anbietet ---- it('zeigt auf der Team-Seite keine Zugangsdaten mehr an', function () { Mail::fake(); $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'); $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(); });