199 lines
8.1 KiB
PHP
199 lines
8.1 KiB
PHP
<?php
|
|
|
|
namespace Tests\Feature;
|
|
|
|
use App\Livewire\Modals\RecoveryCodes;
|
|
use App\Models\User;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\Route;
|
|
use Livewire\Livewire;
|
|
use Tests\TestCase;
|
|
|
|
class RecoveryCodesModalTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
public function test_manage_view_without_fresh_flag_dispatches_nothing_and_hides_codes(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
$codes = $user->replaceRecoveryCodes();
|
|
|
|
// Opened from "Backup-Codes verwalten" with no fresh-generation flag: never reveal.
|
|
// No reveal event fires, and the codes never appear in the server-rendered HTML.
|
|
Livewire::actingAs($user)->test(RecoveryCodes::class)
|
|
->assertNotDispatched('reveal-codes')
|
|
->assertDontSee($codes[0])
|
|
->assertDontSee($codes[7])
|
|
->assertSee(__('auth.recovery_hidden_notice'));
|
|
}
|
|
|
|
public function test_fresh_flag_dispatches_codes_once_and_keeps_them_out_of_the_snapshot(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
$codes = $user->replaceRecoveryCodes();
|
|
session(['2fa.codes_fresh' => now()->timestamp]);
|
|
|
|
// The fresh flag delivers the codes via a one-time transient event — never as a
|
|
// property and never in the server-rendered HTML, so they are absent from the snapshot.
|
|
$component = Livewire::actingAs($user)->test(RecoveryCodes::class)
|
|
->assertDispatched('reveal-codes', codes: $codes)
|
|
->assertDontSee($codes[0])
|
|
->assertDontSee($codes[7]);
|
|
|
|
// Prove the codes are absent from the serialized snapshot itself — the signed
|
|
// wire:snapshot blob a client could capture and replay — not just from visible HTML.
|
|
$this->assertSnapshotHidesCodes($codes, $component);
|
|
|
|
// pull() forgets the flag — it is single-use.
|
|
$this->assertFalse(session()->has('2fa.codes_fresh'));
|
|
}
|
|
|
|
public function test_a_stale_codes_fresh_flag_reveals_nothing(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
$codes = $user->replaceRecoveryCodes();
|
|
|
|
// A reveal flag older than the 10-minute window (e.g. enrollment whose modal never
|
|
// opened) must not reveal the codes when the modal is later mounted.
|
|
session(['2fa.codes_fresh' => now()->subMinutes(11)->timestamp]);
|
|
Livewire::actingAs($user)->test(RecoveryCodes::class)
|
|
->assertNotDispatched('reveal-codes')
|
|
->assertDontSee($codes[0]);
|
|
|
|
// The stale flag is still consumed (pulled), so it cannot trigger a later reveal.
|
|
$this->assertFalse(session()->has('2fa.codes_fresh'));
|
|
}
|
|
|
|
public function test_replayed_or_stale_snapshot_renders_no_codes(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
$codes = $user->replaceRecoveryCodes();
|
|
session(['2fa.codes_fresh' => now()->timestamp]);
|
|
|
|
// Legitimate one-time reveal: consumes the flag, fires the event once, renders no codes.
|
|
$component = Livewire::actingAs($user)->test(RecoveryCodes::class)
|
|
->assertDispatched('reveal-codes', codes: $codes)
|
|
->assertDontSee($codes[0]);
|
|
|
|
// The captured snapshot carries no codes — there is nothing in it to replay.
|
|
$this->assertSnapshotHidesCodes($codes, $component);
|
|
|
|
// Replaying that snapshot = a later roundtrip that hydrates the same state. mount()
|
|
// does not re-run on hydrate, so no reveal-codes event fires and no codes appear; the
|
|
// re-serialized snapshot stays clean too.
|
|
$component->call('$refresh')
|
|
->assertNotDispatched('reveal-codes')
|
|
->assertDontSee($codes[0]);
|
|
$this->assertSnapshotHidesCodes($codes, $component);
|
|
|
|
// A fresh mount from the now-consumed flag (the state a replayed snapshot starts from)
|
|
// dispatches nothing and renders nothing — codes cannot be re-revealed.
|
|
Livewire::actingAs($user)->test(RecoveryCodes::class)
|
|
->assertNotDispatched('reveal-codes')
|
|
->assertDontSee($codes[0]);
|
|
}
|
|
|
|
public function test_regenerate_dispatches_the_new_codes_without_rendering_them(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
$old = $user->replaceRecoveryCodes();
|
|
|
|
// Manage view (no flag) → nothing dispatched → regenerate → the NEW set is dispatched once.
|
|
$component = Livewire::actingAs($user)->test(RecoveryCodes::class)
|
|
->assertNotDispatched('reveal-codes')
|
|
->call('regenerate');
|
|
|
|
$new = $user->fresh()->recoveryCodes();
|
|
$this->assertNotEquals($old, $new);
|
|
|
|
// New set delivered via the transient event; neither the old nor the new set is in the HTML.
|
|
$component->assertDispatched('reveal-codes', codes: $new)
|
|
->assertDontSee($old[0])
|
|
->assertDontSee($new[0]);
|
|
|
|
// The regenerated set is absent from the serialized snapshot too.
|
|
$this->assertSnapshotHidesCodes($new, $component);
|
|
|
|
// Regenerate also grants exactly one download of the fresh set.
|
|
$this->assertTrue(session()->has('2fa.download_grant'));
|
|
}
|
|
|
|
public function test_download_requires_a_fresh_grant(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
$user->replaceRecoveryCodes();
|
|
|
|
// Without a fresh-generation grant, a direct hit of the download route is forbidden,
|
|
// so stored codes can never be re-downloaded by an authed user at will.
|
|
$this->actingAs($user)
|
|
->post(route('two-factor.recovery.download'))
|
|
->assertForbidden();
|
|
|
|
// With a fresh one-time grant (a timestamp set when codes are freshly generated/
|
|
// regenerated) the download succeeds exactly once.
|
|
session(['2fa.download_grant' => now()->timestamp]);
|
|
$this->actingAs($user)
|
|
->post(route('two-factor.recovery.download'))
|
|
->assertOk()
|
|
->assertDownload('clusev-recovery-codes.txt');
|
|
|
|
// The grant is single-use: pull() consumed it, so an immediate re-download 403s.
|
|
$this->actingAs($user)
|
|
->post(route('two-factor.recovery.download'))
|
|
->assertForbidden();
|
|
}
|
|
|
|
public function test_a_stale_download_grant_is_rejected(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
$user->replaceRecoveryCodes();
|
|
|
|
// A grant older than the 10-minute window is refused, so an unused grant cannot
|
|
// linger and be replayed later in the authenticated session.
|
|
session(['2fa.download_grant' => now()->subMinutes(11)->timestamp]);
|
|
$this->actingAs($user)
|
|
->post(route('two-factor.recovery.download'))
|
|
->assertForbidden();
|
|
|
|
// A non-timestamp (legacy bool) grant is likewise refused.
|
|
session(['2fa.download_grant' => true]);
|
|
$this->actingAs($user)
|
|
->post(route('two-factor.recovery.download'))
|
|
->assertForbidden();
|
|
|
|
// A future-dated grant is refused too (only [now-600s, now] is valid).
|
|
session(['2fa.download_grant' => now()->addDay()->timestamp]);
|
|
$this->actingAs($user)
|
|
->post(route('two-factor.recovery.download'))
|
|
->assertForbidden();
|
|
|
|
// A grant within the window still works.
|
|
session(['2fa.download_grant' => now()->timestamp]);
|
|
$this->actingAs($user)
|
|
->post(route('two-factor.recovery.download'))
|
|
->assertOk();
|
|
}
|
|
|
|
public function test_old_recovery_route_is_gone(): void
|
|
{
|
|
$this->assertFalse(Route::has('two-factor.recovery'));
|
|
$this->assertTrue(Route::has('two-factor.recovery.download'));
|
|
}
|
|
|
|
/**
|
|
* Assert that NONE of the given recovery codes appear anywhere in the component's
|
|
* serialized snapshot — the signed wire:snapshot blob a client could capture and replay.
|
|
*
|
|
* @param array<int, string> $codes
|
|
* @param \Livewire\Features\SupportTesting\Testable $component
|
|
*/
|
|
private function assertSnapshotHidesCodes(array $codes, $component): void
|
|
{
|
|
$snapshot = json_encode($component->snapshot);
|
|
|
|
foreach ($codes as $code) {
|
|
$this->assertStringNotContainsString($code, $snapshot);
|
|
}
|
|
}
|
|
}
|