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 $codes * @param Testable $component */ private function assertSnapshotHidesCodes(array $codes, $component): void { $snapshot = json_encode($component->snapshot); foreach ($codes as $code) { $this->assertStringNotContainsString($code, $snapshot); } } }