385 lines
16 KiB
PHP
385 lines
16 KiB
PHP
<?php
|
|
|
|
use App\Livewire\Admin\Settings;
|
|
use App\Livewire\Auth\OperatorLogin;
|
|
use App\Livewire\Auth\OperatorSetPassword;
|
|
use App\Livewire\Auth\ResetPassword;
|
|
use App\Mail\OperatorInvitationMail;
|
|
use App\Models\Operator;
|
|
use App\Models\User;
|
|
use App\Support\Settings as AppSettings;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Hash;
|
|
use Illuminate\Support\Facades\Lang;
|
|
use Illuminate\Support\Facades\Mail;
|
|
use Illuminate\Support\Facades\Password;
|
|
use Livewire\Livewire;
|
|
|
|
/**
|
|
* Ein eingeladener Betreiber vergibt sein Passwort selbst.
|
|
*
|
|
* Vorher erzeugte die Konsole eines und zeigte es dem Inhaber einmal an, damit
|
|
* er es „sicher weitergibt" — per Chat, per Zuruf — für einen Zugang, der die
|
|
* ganze Flotte und jede Kundeninstanz verwaltet. Jetzt geht eine Einladung
|
|
* ohne Passwort hinaus, und niemand kennt ein fremdes.
|
|
*
|
|
* Die tragende Regel dabei ist R21: Konsole und Portal teilen keine Identität,
|
|
* also auch keine Token-Tabelle, keinen Broker und keine Seite.
|
|
*/
|
|
|
|
/**
|
|
* Lädt jemanden ein und gibt das angelegte Konto zurück.
|
|
*
|
|
* Meldet den Inhaber danach ab. Das ist keine Kosmetik: alles, was auf eine
|
|
* Einladung folgt, passiert bei einem ANDEREN Menschen an einem anderen
|
|
* Rechner, und ein Test, der die Sitzung des Inhabers stehen lässt, prüft den
|
|
* Weg des Eingeladenen mit fremden Rechten.
|
|
*/
|
|
function invite(string $email = 'sam@ops.test', string $role = 'Support'): Operator
|
|
{
|
|
Livewire::actingAs(operator('Owner'), 'operator')->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();
|
|
}
|
|
|
|
// ---- 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', 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.
|
|
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 ((new ReflectionObject($instanz))->getProperties(ReflectionProperty::IS_PUBLIC) as $eigenschaft) {
|
|
$wert = $eigenschaft->getValue($instanz);
|
|
|
|
if (! is_string($wert) || $wert === '') {
|
|
continue;
|
|
}
|
|
|
|
expect(Hash::check($wert, $ivy->password))->toBeFalse(
|
|
"Die Eigenschaft {$eigenschaft->getName()} ist das Passwort des neuen Kontos.",
|
|
);
|
|
}
|
|
});
|
|
|
|
// ---- 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 () {
|
|
// `passwords.token` hat in dieser Installation keine Übersetzungsdatei und
|
|
// stünde roh mitten auf der Seite.
|
|
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'));
|
|
});
|
|
|
|
// ---- 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();
|
|
|
|
$html = 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();
|
|
|
|
// Die drei Zeilen, die den Kasten beschriftet haben, gibt es nicht mehr —
|
|
// in keiner der beiden Sprachen.
|
|
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");
|
|
}
|
|
|
|
expect($html)->toContain(e(__('admin_settings.invite_hint', ['hours' => OperatorInvitationMail::validHours()])));
|
|
});
|