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]);
}
}