219 lines
8.4 KiB
PHP
219 lines
8.4 KiB
PHP
<?php
|
|
|
|
namespace Tests\Feature;
|
|
|
|
use App\Livewire\Threats\Index;
|
|
use App\Models\AuditEvent;
|
|
use App\Models\BannedIp;
|
|
use App\Models\User;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Livewire\Livewire;
|
|
use Tests\TestCase;
|
|
|
|
/**
|
|
* The THREATS honeypot dashboard: admin-only access (route middleware + mount guard),
|
|
* the KPI counts + probe feed, and the unban mutation (reuses auth.ip_unbanned audit code).
|
|
*/
|
|
class ThreatsPageTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
private const ATTACKER_IP = '203.0.113.99';
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
Cache::flush();
|
|
}
|
|
|
|
private function admin(): User
|
|
{
|
|
return User::factory()->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('Honeypot-Treffer') // localized action label (DE default)
|
|
->assertSee('Honeytoken benutzt')
|
|
->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('Honeypot-Login-Versuch') // localized action label (DE default)
|
|
->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' => '<script>alert(1)</script>', 'tried_pass' => 'x'],
|
|
]);
|
|
|
|
Livewire::actingAs($this->admin())
|
|
->test(Index::class)
|
|
->assertDontSee('<script>alert(1)</script>', 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]);
|
|
}
|
|
}
|