create(['must_change_password' => false]); } private function operator(): User { return User::factory()->operator()->create(['must_change_password' => false]); } private function viewer(): User { return User::factory()->viewer()->create(['must_change_password' => false]); } // ── Access control ────────────────────────────────────────────────────────── public function test_admin_can_load_the_threats_route(): void { $this->actingAs($this->admin()); $this->get('/threats')->assertOk(); } public function test_operator_is_forbidden_from_the_threats_route(): void { $this->actingAs($this->operator()); $this->get('/threats')->assertForbidden(); } public function test_viewer_is_forbidden_from_the_threats_route(): void { $this->actingAs($this->viewer()); $this->get('/threats')->assertForbidden(); } public function test_admin_can_mount_the_component(): void { Livewire::actingAs($this->admin()) ->test(Index::class) ->assertOk(); } public function test_operator_mount_aborts_403(): void { Livewire::actingAs($this->operator()) ->test(Index::class) ->assertForbidden(); } public function test_viewer_mount_aborts_403(): void { Livewire::actingAs($this->viewer()) ->test(Index::class) ->assertForbidden(); } // ── Feed + KPIs ───────────────────────────────────────────────────────────── public function test_page_shows_honeypot_events_and_correct_kpis(): void { // Two probes within 24h + one honeytoken trip + one stale probe (outside the 24h window). AuditEvent::create(['actor' => 'system', 'action' => 'security.honeypot_hit', 'target' => '/wp-login.php', 'ip' => self::ATTACKER_IP]); AuditEvent::create(['actor' => 'system', 'action' => 'security.honeypot_hit', 'target' => '/.env', 'ip' => self::ATTACKER_IP]); AuditEvent::create(['actor' => 'system', 'action' => 'security.honeytoken_used', 'target' => 'db_password', 'ip' => self::ATTACKER_IP]); AuditEvent::create(['actor' => 'system', 'action' => 'security.honeypot_hit', 'target' => '/old', 'ip' => '198.51.100.7', 'created_at' => now()->subDays(3)]); BannedIp::create(['ip' => self::ATTACKER_IP, 'banned_until' => now()->addHour(), 'reason' => 'honeypot', 'attempts' => 1]); Livewire::actingAs($this->admin()) ->test(Index::class) ->assertSee(self::ATTACKER_IP) ->assertSee('/wp-login.php') ->assertSee('Angriffsversuch') // disguised action label (DE default) — no "Honeypot" leak ->assertSee('Kompromittierte Zugangsdaten') ->assertViewHas('probes_24h', 2) // 2 recent hits, the 3-day-old one excluded ->assertViewHas('banned_total', 1) ->assertViewHas('honeytoken_trips', 1) ->assertViewHas('top_ip', self::ATTACKER_IP); } public function test_page_shows_honeypot_login_attempts_with_tried_credentials(): void { AuditEvent::create([ 'actor' => 'system', 'action' => 'security.honeypot_login', 'target' => '/wp-login.php', 'ip' => self::ATTACKER_IP, 'meta' => ['tried_user' => 'admin', 'tried_pass' => 'hunter2', 'method' => 'POST'], ]); Livewire::actingAs($this->admin()) ->test(Index::class) ->assertSee(self::ATTACKER_IP) ->assertSee('Login-Angriffsversuch') // disguised action label (DE default) — no "Honeypot" leak ->assertSee('admin') // captured tried_user ->assertSee('hunter2') // captured tried_pass ->assertViewHas('login_attempts_24h', 1); } public function test_tried_credentials_are_html_escaped(): void { // Attacker-controlled meta must render escaped, never as live markup. AuditEvent::create([ 'actor' => 'system', 'action' => 'security.honeypot_login', 'target' => '/wp-login.php', 'ip' => self::ATTACKER_IP, 'meta' => ['tried_user' => '', 'tried_pass' => 'x'], ]); Livewire::actingAs($this->admin()) ->test(Index::class) ->assertDontSee('', false) // raw markup must NOT be present ->assertSee('<script>', false); // it is escaped instead } public function test_top_ip_is_null_when_there_is_no_traffic(): void { Livewire::actingAs($this->admin()) ->test(Index::class) ->assertViewHas('top_ip', null) ->assertViewHas('probes_24h', 0) ->assertSee('—'); } public function test_search_filters_the_feed_by_ip(): void { AuditEvent::create(['actor' => 'system', 'action' => 'security.honeypot_hit', 'target' => '/wp-login.php', 'ip' => self::ATTACKER_IP]); AuditEvent::create(['actor' => 'system', 'action' => 'security.honeypot_hit', 'target' => '/xmlrpc.php', 'ip' => '198.51.100.7']); Livewire::actingAs($this->admin()) ->test(Index::class) ->set('q', self::ATTACKER_IP) ->assertViewHas('events', fn ($e) => $e->total() === 1) ->assertSee('/wp-login.php') ->assertDontSee('/xmlrpc.php'); } // ── Unban mutation ────────────────────────────────────────────────────────── public function test_admin_can_unban_and_it_is_audited(): void { BannedIp::create(['ip' => self::ATTACKER_IP, 'banned_until' => now()->addHour(), 'reason' => 'honeypot', 'attempts' => 1]); Livewire::actingAs($this->admin()) ->test(Index::class) ->call('unban', self::ATTACKER_IP) ->assertDispatched('notify'); $this->assertDatabaseMissing('banned_ips', ['ip' => self::ATTACKER_IP]); $this->assertTrue( AuditEvent::where('action', 'auth.ip_unbanned') ->where('target', self::ATTACKER_IP) ->exists() ); } public function test_operator_cannot_unban(): void { BannedIp::create(['ip' => self::ATTACKER_IP, 'banned_until' => now()->addHour(), 'reason' => 'honeypot', 'attempts' => 1]); // An admin boots the component so the snapshot is valid, then we swap to an operator and // call the mutation — the unban() abort_unless must still fire 403 (mid-session demotion or // a hand-crafted /livewire/update). Mirrors RbacNetworkGateTest::test_operator_cannot_add… $component = Livewire::actingAs($this->admin())->test(Index::class)->assertOk(); $this->actingAs($this->operator()); $component->call('unban', self::ATTACKER_IP)->assertForbidden(); $this->assertDatabaseHas('banned_ips', ['ip' => self::ATTACKER_IP]); } public function test_viewer_cannot_unban(): void { BannedIp::create(['ip' => self::ATTACKER_IP, 'banned_until' => now()->addHour(), 'reason' => 'honeypot', 'attempts' => 1]); $component = Livewire::actingAs($this->admin())->test(Index::class)->assertOk(); $this->actingAs($this->viewer()); $component->call('unban', self::ATTACKER_IP)->assertForbidden(); $this->assertDatabaseHas('banned_ips', ['ip' => self::ATTACKER_IP]); } }